Switch to tRPC (#173)
21
README.md
|
@ -74,16 +74,17 @@ yarn start
|
|||
|
||||
## ⚙️ Configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DATABASE_URL | postgres://postgres:postgres@rallly_db:5432/db | A postgres database URL. Leave out if using the docker-compose file since it will spin up and connect to its own database instance. |
|
||||
| SECRET_PASSWORD | - | A long string (minimum 32 characters) that is used to encrypt session data. |
|
||||
| SUPPORT_EMAIL | - | An email address that will appear as the FROM email for all emails being sent out. |
|
||||
| SMTP_HOST | - | Host name of your SMTP server |
|
||||
| SMTP_PORT | - | Port of your SMTP server |
|
||||
| SMTP_SECURE | false | Set to "true" if SSL is enabled for your SMTP connection |
|
||||
| SMTP_USER | - | Username to use for your SMTP connection |
|
||||
| SMTP_PWD | - | Password to use for your SMTP connection |
|
||||
| Parameter | Default | Description |
|
||||
| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| NEXT_PUBLIC_BASE_URL | http://localhost:3000 | The hosting url of the server, used for creating links and making api calls from the client. |
|
||||
| DATABASE_URL | postgres://postgres:postgres@rallly_db:5432/db | A postgres database URL. Leave out if using the docker-compose file since it will spin up and connect to its own database instance. |
|
||||
| SECRET_PASSWORD | - | A long string (minimum 32 characters) that is used to encrypt session data. |
|
||||
| SUPPORT_EMAIL | - | An email address that will appear as the FROM email for all emails being sent out. |
|
||||
| SMTP_HOST | - | Host name of your SMTP server |
|
||||
| SMTP_PORT | - | Port of your SMTP server |
|
||||
| SMTP_SECURE | false | Set to "true" if SSL is enabled for your SMTP connection |
|
||||
| SMTP_USER | - | Username to use for your SMTP connection |
|
||||
| SMTP_PWD | - | Password to use for your SMTP connection |
|
||||
|
||||
## 👨💻 Contributors
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface AddParticipantPayload {
|
||||
pollId: string;
|
||||
name: string;
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
|
||||
export type AddParticipantResponse = Participant & {
|
||||
votes: Vote[];
|
||||
};
|
||||
|
||||
export const addParticipant = async (
|
||||
payload: AddParticipantPayload,
|
||||
): Promise<AddParticipantResponse> => {
|
||||
const res = await axios.post<AddParticipantResponse>(
|
||||
`/api/poll/${payload.pollId}/participant`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return res.data;
|
||||
};
|
|
@ -1,18 +0,0 @@
|
|||
import { Comment } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface CreateCommentPayload {
|
||||
pollId: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export const createComment = async (
|
||||
payload: CreateCommentPayload,
|
||||
): Promise<Comment> => {
|
||||
const { data } = await axios.post<Comment>(
|
||||
`/api/poll/${payload.pollId}/comments`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
import { Poll } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export const createDemo = async (): Promise<Poll> => {
|
||||
const { data } = await axios.post<Poll>("/api/poll/demo");
|
||||
return data;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
import { Poll } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface CreatePollPayload {
|
||||
title: string;
|
||||
type: "date";
|
||||
timeZone?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
options: string[];
|
||||
demo?: boolean;
|
||||
}
|
||||
|
||||
export const createPoll = async (payload: CreatePollPayload): Promise<Poll> => {
|
||||
const { data } = await axios.post<Poll>("/api/poll", payload);
|
||||
return data;
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import axios from "axios";
|
||||
|
||||
export interface DeleteParticipantPayload {
|
||||
pollId: string;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export const deleteParticipant = async (
|
||||
payload: DeleteParticipantPayload,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { pollId, participantId } = payload;
|
||||
await axios.delete(`/api/poll/${pollId}/participant/${participantId}`);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
|
||||
|
||||
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
|
||||
options: Option[];
|
||||
participants: Array<Participant & { votes: Vote[] }>;
|
||||
user: User;
|
||||
role: "admin" | "participant";
|
||||
links: Array<Link>;
|
||||
pollId: string;
|
||||
}
|
||||
|
||||
export interface GetPollResponse extends Omit<GetPollApiResponse, "createdAt"> {
|
||||
createdAt: string;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface UpdateParticipantPayload {
|
||||
pollId: string;
|
||||
participantId: string;
|
||||
name: string;
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
|
||||
export const updateParticipant = async (
|
||||
payload: UpdateParticipantPayload,
|
||||
): Promise<Participant & { votes: Vote[] }> => {
|
||||
const { pollId, participantId, ...body } = payload;
|
||||
const res = await axios.patch<Participant & { votes: Vote[] }>(
|
||||
`/api/poll/${pollId}/participant/${participantId}`,
|
||||
body,
|
||||
);
|
||||
return res.data;
|
||||
};
|
|
@ -1,25 +0,0 @@
|
|||
import { Poll } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface UpdatePollPayload {
|
||||
title?: string;
|
||||
timeZone?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
optionsToDelete?: string[];
|
||||
optionsToAdd?: string[];
|
||||
notifications?: boolean;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export const updatePoll = async (
|
||||
urlId: string,
|
||||
payload: UpdatePollPayload,
|
||||
): Promise<Poll> => {
|
||||
try {
|
||||
const { data } = await axios.patch<Poll>(`/api/poll/${urlId}`, payload);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
|
@ -1,145 +0,0 @@
|
|||
import { updatePoll, UpdatePollPayload } from "api-client/update-poll";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
|
||||
import { addParticipant } from "../../api-client/add-participant";
|
||||
import {
|
||||
deleteParticipant,
|
||||
DeleteParticipantPayload,
|
||||
} from "../../api-client/delete-participant";
|
||||
import { GetPollResponse } from "../../api-client/get-poll";
|
||||
import { updateParticipant } from "../../api-client/update-participant";
|
||||
import { usePoll } from "../poll-context";
|
||||
import { useSession } from "../session";
|
||||
import { ParticipantForm } from "./types";
|
||||
|
||||
export const useAddParticipantMutation = (pollId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const session = useSession();
|
||||
const plausible = usePlausible();
|
||||
const { options } = usePoll();
|
||||
|
||||
return useMutation(
|
||||
(payload: ParticipantForm) =>
|
||||
addParticipant({
|
||||
pollId,
|
||||
name: payload.name.trim(),
|
||||
votes: options.map(
|
||||
(option, i) =>
|
||||
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
|
||||
),
|
||||
}),
|
||||
{
|
||||
onSuccess: (participant) => {
|
||||
plausible("Add participant");
|
||||
queryClient.setQueryData<GetPollResponse>(
|
||||
["getPoll", pollId],
|
||||
(poll) => {
|
||||
if (!poll) {
|
||||
throw new Error(
|
||||
"Tried to update poll but no result found in query cache",
|
||||
);
|
||||
}
|
||||
poll.participants = [participant, ...poll.participants];
|
||||
return poll;
|
||||
},
|
||||
);
|
||||
session.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateParticipantMutation = (pollId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
const { options } = usePoll();
|
||||
|
||||
return useMutation(
|
||||
(payload: ParticipantForm & { participantId: string }) =>
|
||||
updateParticipant({
|
||||
pollId,
|
||||
participantId: payload.participantId,
|
||||
name: payload.name.trim(),
|
||||
votes: options.map(
|
||||
(option, i) =>
|
||||
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
|
||||
),
|
||||
}),
|
||||
{
|
||||
onSuccess: (participant) => {
|
||||
plausible("Update participant");
|
||||
queryClient.setQueryData<GetPollResponse>(
|
||||
["getPoll", pollId],
|
||||
(poll) => {
|
||||
if (!poll) {
|
||||
throw new Error(
|
||||
"Tried to update poll but no result found in query cache",
|
||||
);
|
||||
}
|
||||
|
||||
poll.participants = poll.participants.map((p) =>
|
||||
p.id === participant.id ? participant : p,
|
||||
);
|
||||
|
||||
return poll;
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(["getPoll", pollId]);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteParticipantMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
const { poll } = usePoll();
|
||||
return useMutation(
|
||||
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
|
||||
{
|
||||
onMutate: ({ participantId }) => {
|
||||
queryClient.setQueryData<GetPollResponse>(
|
||||
["getPoll", poll.urlId],
|
||||
(poll) => {
|
||||
if (!poll) {
|
||||
throw new Error(
|
||||
"Tried to update poll but no result found in query cache",
|
||||
);
|
||||
}
|
||||
|
||||
poll.participants = poll.participants.filter(
|
||||
({ id }) => id !== participantId,
|
||||
);
|
||||
|
||||
return poll;
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
plausible("Remove participant");
|
||||
},
|
||||
onSettled: (_data, _error, { pollId }) => {
|
||||
queryClient.invalidateQueries(["getPoll", pollId]);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdatePollMutation = () => {
|
||||
const { poll } = usePoll();
|
||||
const plausible = usePlausible();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(payload: UpdatePollPayload) => updatePoll(poll.urlId, payload),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["getPoll", poll.urlId], data);
|
||||
plausible("Updated poll");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
|
@ -1,63 +0,0 @@
|
|||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { validEmail } from "utils/form-validation";
|
||||
|
||||
import Button from "@/components/button";
|
||||
|
||||
const GuestSession: React.VoidFunctionComponent = () => {
|
||||
const { handleSubmit, register, formState, getValues } = useForm<{
|
||||
email: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="card border-amber-500 ring-2 ring-amber-500/20">
|
||||
<h2>Guest session</h2>
|
||||
<p>
|
||||
Guest sessions allow us to remember your device so that you can edit
|
||||
your votes and comments later. However, these sessions are temporary and
|
||||
when they end, cannot be resumed.{" "}
|
||||
<a href="">Read more about guest sessions.</a>
|
||||
</p>
|
||||
<p>Login with your email to make sure you don't lose access:</p>
|
||||
{formState.submitCount > 0 ? (
|
||||
<div>
|
||||
An email has been sent to <strong>{getValues("email")}</strong>.
|
||||
Please check your inbox.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(({ email }) => {
|
||||
axios.post("/api/login", { email });
|
||||
})}
|
||||
>
|
||||
<input
|
||||
{...register("email", {
|
||||
validate: validEmail,
|
||||
})}
|
||||
className={clsx("input w-full", {
|
||||
"input-error": formState.errors.email,
|
||||
})}
|
||||
placeholder="Email address"
|
||||
/>
|
||||
{formState.errors.email ? (
|
||||
<div className="mt-1 text-sm text-red-500">
|
||||
Please enter a valid email address
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<Button htmlType="submit" type="primary">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestSession;
|
1
declarations/environment.d.ts
vendored
|
@ -4,6 +4,7 @@ declare global {
|
|||
DATABASE_URL: string;
|
||||
NODE_ENV: "development" | "production";
|
||||
SECRET_PASSWORD: string;
|
||||
NEXT_PUBLIC_LEGACY_POLLS?: string;
|
||||
NEXT_PUBLIC_MAINTENANCE_MODE?: string;
|
||||
PLAUSIBLE_DOMAIN?: string;
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;
|
||||
|
|
6
declarations/i18next.d.ts
vendored
|
@ -1,8 +1,8 @@
|
|||
import "react-i18next";
|
||||
|
||||
import app from "../public/locales/en/app.json";
|
||||
import homepage from "../public/locales/en/homepage.json";
|
||||
import support from "../public/locales/en/support.json";
|
||||
import app from "~/public/locales/en/app.json";
|
||||
import homepage from "~/public/locales/en/homepage.json";
|
||||
import support from "~/public/locales/en/support.json";
|
||||
|
||||
declare module "next-i18next" {
|
||||
interface Resources {
|
||||
|
|
12
package.json
|
@ -16,11 +16,15 @@
|
|||
"@floating-ui/react-dom-interactions": "^0.4.0",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@next/bundle-analyzer": "^12.1.0",
|
||||
"@prisma/client": "^3.13.0",
|
||||
"@prisma/client": "^3.14.0",
|
||||
"@sentry/nextjs": "^6.19.3",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"@trpc/client": "^9.23.2",
|
||||
"@trpc/next": "^9.23.2",
|
||||
"@trpc/react": "^9.23.2",
|
||||
"@trpc/server": "^9.23.2",
|
||||
"axios": "^0.24.0",
|
||||
"clsx": "^1.1.1",
|
||||
"date-fns": "^2.28.0",
|
||||
|
@ -37,7 +41,7 @@
|
|||
"next-i18next": "^10.5.0",
|
||||
"next-plausible": "^3.1.9",
|
||||
"nodemailer": "^6.7.2",
|
||||
"prisma": "^3.13.0",
|
||||
"prisma": "^3.14.0",
|
||||
"react": "17.0.2",
|
||||
"react-big-calendar": "^0.38.9",
|
||||
"react-dom": "17.0.2",
|
||||
|
@ -50,8 +54,10 @@
|
|||
"react-use": "^17.3.2",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"spacetime": "^7.1.2",
|
||||
"superjson": "^1.9.1",
|
||||
"timezone-soft": "^1.3.1",
|
||||
"typescript": "^4.5.2"
|
||||
"typescript": "^4.5.2",
|
||||
"zod": "^3.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.20.1",
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import "tailwindcss/tailwind.css";
|
||||
import "../style.css";
|
||||
|
||||
import axios from "axios";
|
||||
import { NextPage } from "next";
|
||||
import { AppProps } from "next/app";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import PlausibleProvider from "next-plausible";
|
||||
import toast, { Toaster } from "react-hot-toast";
|
||||
import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
|
||||
|
||||
import Maintenance from "@/components/maintenance";
|
||||
import ModalProvider from "@/components/modal/modal-provider";
|
||||
import PreferencesProvider from "@/components/preferences/preferences-provider";
|
||||
|
||||
const CrispChat = dynamic(() => import("@/components/crisp-chat"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 500) {
|
||||
toast.error(
|
||||
"Uh oh! Something went wrong. The issue has been logged and we'll fix it as soon as possible. Please try again later.",
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
|
||||
if (process.env.NEXT_PUBLIC_MAINTENANCE_MODE === "1") {
|
||||
return <Maintenance />;
|
||||
}
|
||||
return (
|
||||
<PlausibleProvider
|
||||
domain="rallly.co"
|
||||
customDomain={process.env.PLAUSIBLE_DOMAIN}
|
||||
trackOutboundLinks={true}
|
||||
selfHosted={true}
|
||||
enabled={!!process.env.PLAUSIBLE_DOMAIN}
|
||||
>
|
||||
<PreferencesProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
<CrispChat />
|
||||
<Toaster />
|
||||
<ModalProvider>
|
||||
<Component {...pageProps} />
|
||||
</ModalProvider>
|
||||
</QueryClientProvider>
|
||||
</PreferencesProvider>
|
||||
</PlausibleProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default appWithTranslation(MyApp);
|
|
@ -1,35 +0,0 @@
|
|||
import absoluteUrl from "utils/absolute-url";
|
||||
import { sendEmailTemplate } from "utils/api-utils";
|
||||
import { createToken, withSessionRoute } from "utils/auth";
|
||||
|
||||
export default withSessionRoute(async (req, res) => {
|
||||
switch (req.method) {
|
||||
case "POST": {
|
||||
const email = req.body.email;
|
||||
const homePageUrl = absoluteUrl(req).origin;
|
||||
const token = await createToken({
|
||||
email,
|
||||
guestId: req.session.user?.isGuest ? req.session.user.id : undefined,
|
||||
path: req.body.path,
|
||||
});
|
||||
|
||||
const loginUrl = `${homePageUrl}/login?code=${token}`;
|
||||
|
||||
await sendEmailTemplate({
|
||||
templateName: "login",
|
||||
to: email,
|
||||
subject: "Rallly - Login",
|
||||
templateVars: {
|
||||
loginUrl,
|
||||
homePageUrl,
|
||||
},
|
||||
});
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.status(405);
|
||||
return;
|
||||
}
|
||||
});
|
|
@ -1,6 +0,0 @@
|
|||
import { withSessionRoute } from "utils/auth";
|
||||
|
||||
export default withSessionRoute((req, res) => {
|
||||
req.session.destroy();
|
||||
res.send({ ok: true });
|
||||
});
|
|
@ -1,108 +0,0 @@
|
|||
import { sendEmailTemplate } from "utils/api-utils";
|
||||
import { createToken, withSessionRoute } from "utils/auth";
|
||||
import { nanoid } from "utils/nanoid";
|
||||
|
||||
import { CreatePollPayload } from "../../api-client/create-poll";
|
||||
import { prisma } from "../../db";
|
||||
import absoluteUrl from "../../utils/absolute-url";
|
||||
|
||||
export default withSessionRoute(async (req, res) => {
|
||||
switch (req.method) {
|
||||
case "POST": {
|
||||
const adminUrlId = await nanoid();
|
||||
const payload: CreatePollPayload = req.body;
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
data: {
|
||||
urlId: await nanoid(),
|
||||
title: payload.title,
|
||||
type: payload.type,
|
||||
timeZone: payload.timeZone,
|
||||
location: payload.location,
|
||||
description: payload.description,
|
||||
authorName: payload.user.name,
|
||||
demo: payload.demo,
|
||||
verified:
|
||||
req.session.user?.isGuest === false &&
|
||||
req.session.user.email === payload.user.email,
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
email: payload.user.email,
|
||||
},
|
||||
create: {
|
||||
id: await nanoid(),
|
||||
...payload.user,
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
createMany: {
|
||||
data: payload.options.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
links: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
urlId: adminUrlId,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
urlId: await nanoid(),
|
||||
role: "participant",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const homePageUrl = absoluteUrl(req).origin;
|
||||
const pollUrl = `${homePageUrl}/admin/${adminUrlId}`;
|
||||
|
||||
try {
|
||||
if (poll.verified) {
|
||||
await sendEmailTemplate({
|
||||
templateName: "new-poll-verified",
|
||||
to: payload.user.email,
|
||||
subject: `Rallly: ${poll.title}`,
|
||||
templateVars: {
|
||||
title: poll.title,
|
||||
name: payload.user.name,
|
||||
pollUrl,
|
||||
homePageUrl,
|
||||
supportEmail: process.env.SUPPORT_EMAIL,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const verificationCode = await createToken({
|
||||
pollId: poll.urlId,
|
||||
});
|
||||
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
|
||||
|
||||
await sendEmailTemplate({
|
||||
templateName: "new-poll",
|
||||
to: payload.user.email,
|
||||
subject: `Rallly: ${poll.title} - Verify your email address`,
|
||||
templateVars: {
|
||||
title: poll.title,
|
||||
name: payload.user.name,
|
||||
pollUrl,
|
||||
verifyEmailUrl,
|
||||
homePageUrl,
|
||||
supportEmail: process.env.SUPPORT_EMAIL,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return res.json({ urlId: adminUrlId, authorName: poll.authorName });
|
||||
}
|
||||
default:
|
||||
}
|
||||
});
|
|
@ -1,148 +0,0 @@
|
|||
import { GetPollApiResponse } from "api-client/get-poll";
|
||||
import { resetDates } from "utils/legacy-utils";
|
||||
|
||||
import { UpdatePollPayload } from "../../../api-client/update-poll";
|
||||
import { prisma } from "../../../db";
|
||||
import { withLink } from "../../../utils/api-utils";
|
||||
|
||||
export default withLink<
|
||||
GetPollApiResponse | { status: number; message: string }
|
||||
>(async ({ req, res, link }) => {
|
||||
const pollId = link.pollId;
|
||||
|
||||
switch (req.method) {
|
||||
case "GET": {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
},
|
||||
include: {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
},
|
||||
user: true,
|
||||
links: link.role === "admin",
|
||||
},
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
return res.status(404).json({ status: 404, message: "Poll not found" });
|
||||
}
|
||||
|
||||
if (
|
||||
poll.legacy &&
|
||||
// has converted options without timezone
|
||||
poll.options.every(({ value }) => value.indexOf("T") === -1)
|
||||
) {
|
||||
// We need to reset the dates for polls that lost their timezone data because some users
|
||||
// of the old version will end up seeing the wrong dates
|
||||
const fixedPoll = await resetDates(poll.urlId);
|
||||
|
||||
if (fixedPoll) {
|
||||
return res.json({
|
||||
...fixedPoll,
|
||||
role: link.role,
|
||||
urlId: link.urlId,
|
||||
pollId: poll.urlId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...poll,
|
||||
role: link.role,
|
||||
urlId: link.urlId,
|
||||
pollId: poll.urlId,
|
||||
});
|
||||
}
|
||||
case "PATCH": {
|
||||
if (link.role !== "admin") {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ status: 401, message: "Permission denied" });
|
||||
}
|
||||
|
||||
const payload: Partial<UpdatePollPayload> = req.body;
|
||||
if (payload.optionsToDelete && payload.optionsToDelete.length > 0) {
|
||||
await prisma.option.deleteMany({
|
||||
where: {
|
||||
pollId,
|
||||
id: {
|
||||
in: payload.optionsToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payload.optionsToAdd && payload.optionsToAdd.length > 0) {
|
||||
await prisma.option.createMany({
|
||||
data: payload.optionsToAdd.map((optionValue) => ({
|
||||
value: optionValue,
|
||||
pollId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const poll = await prisma.poll.update({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
},
|
||||
data: {
|
||||
title: payload.title,
|
||||
location: payload.location,
|
||||
description: payload.description,
|
||||
timeZone: payload.timeZone,
|
||||
notifications: payload.notifications,
|
||||
closed: payload.closed,
|
||||
},
|
||||
include: {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
},
|
||||
user: true,
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
return res.status(404).json({ status: 404, message: "Poll not found" });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...poll,
|
||||
role: link.role,
|
||||
urlId: link.urlId,
|
||||
pollId: poll.urlId,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return res
|
||||
.status(405)
|
||||
.json({ status: 405, message: "Method not allowed" });
|
||||
}
|
||||
});
|
|
@ -1,56 +0,0 @@
|
|||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||
|
||||
import { prisma } from "../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||
|
||||
export default withSessionRoute(
|
||||
withLink(async ({ req, res, link }) => {
|
||||
switch (req.method) {
|
||||
case "GET": {
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
pollId: link.pollId,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return res.json({ comments });
|
||||
}
|
||||
case "POST": {
|
||||
if (!req.session.user) {
|
||||
await createGuestUser(req);
|
||||
}
|
||||
|
||||
const newComment = await prisma.comment.create({
|
||||
data: {
|
||||
content: req.body.content,
|
||||
pollId: link.pollId,
|
||||
authorName: req.body.authorName,
|
||||
userId: req.session.user?.isGuest
|
||||
? undefined
|
||||
: req.session.user?.id,
|
||||
guestId: req.session.user?.isGuest
|
||||
? req.session.user.id
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await sendNotification(req, link.pollId, {
|
||||
type: "newComment",
|
||||
authorName: newComment.authorName,
|
||||
});
|
||||
|
||||
return res.json(newComment);
|
||||
}
|
||||
|
||||
default:
|
||||
return res
|
||||
.status(405)
|
||||
.json({ status: 405, message: "Method not allowed" });
|
||||
}
|
||||
}),
|
||||
);
|
|
@ -1,36 +0,0 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { prisma } from "../../../../../db";
|
||||
import { getQueryParam } from "../../../../../utils/api-utils";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const urlId = getQueryParam(req, "urlId");
|
||||
const commentId = getQueryParam(req, "commentId");
|
||||
|
||||
const link = await prisma.link.findUnique({ where: { urlId } });
|
||||
|
||||
if (!link) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
switch (req.method) {
|
||||
case "DELETE":
|
||||
await prisma.comment.delete({
|
||||
where: {
|
||||
id_pollId: {
|
||||
id: commentId,
|
||||
pollId: link.pollId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.end();
|
||||
default:
|
||||
return res
|
||||
.status(405)
|
||||
.json({ status: 405, message: "Method not allowed" });
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||
|
||||
import { AddParticipantPayload } from "../../../../api-client/add-participant";
|
||||
import { prisma } from "../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||
|
||||
export default withSessionRoute(
|
||||
withLink(async ({ req, res, link }) => {
|
||||
switch (req.method) {
|
||||
case "POST": {
|
||||
const payload: AddParticipantPayload = req.body;
|
||||
|
||||
if (!req.session.user) {
|
||||
await createGuestUser(req);
|
||||
}
|
||||
|
||||
const participant = await prisma.participant.create({
|
||||
data: {
|
||||
pollId: link.pollId,
|
||||
name: payload.name,
|
||||
userId:
|
||||
req.session.user?.isGuest === false
|
||||
? req.session.user.id
|
||||
: undefined,
|
||||
guestId:
|
||||
req.session.user?.isGuest === true
|
||||
? req.session.user.id
|
||||
: undefined,
|
||||
votes: {
|
||||
createMany: {
|
||||
data: payload.votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId: link.pollId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendNotification(req, link.pollId, {
|
||||
type: "newParticipant",
|
||||
participantName: participant.name,
|
||||
});
|
||||
|
||||
return res.json(participant);
|
||||
}
|
||||
default:
|
||||
return res.status(405).json({ ok: 1 });
|
||||
}
|
||||
}),
|
||||
);
|
|
@ -1,57 +0,0 @@
|
|||
import { UpdateParticipantPayload } from "api-client/update-participant";
|
||||
|
||||
import { prisma } from "../../../../../db";
|
||||
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
|
||||
|
||||
export default withLink(async ({ req, res, link }) => {
|
||||
const participantId = getQueryParam(req, "participantId");
|
||||
|
||||
const pollId = link.pollId;
|
||||
switch (req.method) {
|
||||
case "PATCH":
|
||||
const payload: UpdateParticipantPayload = req.body;
|
||||
|
||||
const participant = await prisma.participant.update({
|
||||
where: {
|
||||
id_pollId: {
|
||||
id: participantId,
|
||||
pollId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
votes: {
|
||||
deleteMany: {
|
||||
pollId,
|
||||
},
|
||||
createMany: {
|
||||
data: payload.votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
name: req.body.name,
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(participant);
|
||||
|
||||
case "DELETE":
|
||||
await prisma.participant.delete({
|
||||
where: {
|
||||
id_pollId: {
|
||||
id: participantId,
|
||||
pollId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.end();
|
||||
default:
|
||||
return res.status(405);
|
||||
}
|
||||
});
|
|
@ -1,109 +0,0 @@
|
|||
import absoluteUrl from "utils/absolute-url";
|
||||
import {
|
||||
createToken,
|
||||
decryptToken,
|
||||
mergeGuestsIntoUser as mergeUsersWithGuests,
|
||||
withSessionRoute,
|
||||
} from "utils/auth";
|
||||
|
||||
import { prisma } from "../../../../db";
|
||||
import { sendEmailTemplate, withLink } from "../../../../utils/api-utils";
|
||||
|
||||
export default withSessionRoute(
|
||||
withLink(async ({ req, res, link }) => {
|
||||
if (req.method === "POST") {
|
||||
if (link.role !== "admin") {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ status: 401, message: "Only admins can verify polls" });
|
||||
}
|
||||
const verificationCode = req.body ? req.body.verificationCode : undefined;
|
||||
if (!verificationCode) {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: link.pollId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ status: 404, message: "Poll not found" });
|
||||
}
|
||||
|
||||
const homePageUrl = absoluteUrl(req).origin;
|
||||
const pollUrl = `${homePageUrl}/admin/${link.urlId}`;
|
||||
const token = await createToken({
|
||||
pollId: link.pollId,
|
||||
});
|
||||
const verifyEmailUrl = `${pollUrl}?code=${token}`;
|
||||
|
||||
await sendEmailTemplate({
|
||||
templateName: "new-poll",
|
||||
to: poll.user.email,
|
||||
subject: `Rallly: ${poll.title} - Verify your email address`,
|
||||
templateVars: {
|
||||
title: poll.title,
|
||||
name: poll.user.name,
|
||||
pollUrl,
|
||||
verifyEmailUrl,
|
||||
homePageUrl,
|
||||
supportEmail: process.env.SUPPORT_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send("ok");
|
||||
}
|
||||
try {
|
||||
const { pollId } = await decryptToken<{
|
||||
pollId: string;
|
||||
}>(verificationCode);
|
||||
|
||||
if (pollId !== link.pollId) {
|
||||
res.status(401).json({
|
||||
status: 401,
|
||||
message: "Invalid token",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const poll = await prisma.poll.update({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
},
|
||||
data: {
|
||||
verified: true,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
// If logged in as guest, we update all participants
|
||||
// and comments by this guest to the user that we just authenticated
|
||||
if (req.session.user?.isGuest) {
|
||||
await mergeUsersWithGuests(poll.user.id, [req.session.user.id]);
|
||||
}
|
||||
|
||||
req.session.user = {
|
||||
id: poll.user.id,
|
||||
isGuest: false,
|
||||
name: poll.user.name,
|
||||
email: poll.user.email,
|
||||
};
|
||||
await req.session.save();
|
||||
|
||||
return res.send("ok");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ status: 500, message: "Could not verify poll" });
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
.status(405)
|
||||
.json({ status: 405, message: "Invalid http method" });
|
||||
}),
|
||||
);
|
|
@ -1,135 +0,0 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import { addMinutes } from "date-fns";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import absoluteUrl from "utils/absolute-url";
|
||||
import { nanoid } from "utils/nanoid";
|
||||
|
||||
import { prisma } from "../../../db";
|
||||
|
||||
const participantData: Array<{ name: string; votes: VoteType[] }> = [
|
||||
{
|
||||
name: "Reed",
|
||||
votes: ["yes", "no", "ifNeedBe", "no"],
|
||||
},
|
||||
{
|
||||
name: "Susan",
|
||||
votes: ["yes", "yes", "yes", "no"],
|
||||
},
|
||||
{
|
||||
name: "Johnny",
|
||||
votes: ["no", "no", "yes", "yes"],
|
||||
},
|
||||
{
|
||||
name: "Ben",
|
||||
votes: ["yes", "yes", "yes", "yes"],
|
||||
},
|
||||
];
|
||||
|
||||
const optionValues = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
switch (req.method) {
|
||||
case "POST": {
|
||||
const adminUrlId = await nanoid();
|
||||
const demoUser = { name: "John Example", email: "noreply@rallly.co" };
|
||||
const today = new Date();
|
||||
|
||||
const options: Array<{ value: string; id: string }> = [];
|
||||
|
||||
for (let i = 0; i < optionValues.length; i++) {
|
||||
options.push({ id: await nanoid(), value: optionValues[i] });
|
||||
}
|
||||
|
||||
const participants: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
guestId: string;
|
||||
createdAt: Date;
|
||||
}> = [];
|
||||
|
||||
const votes: Array<{
|
||||
optionId: string;
|
||||
participantId: string;
|
||||
type: VoteType;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < participantData.length; i++) {
|
||||
const { name, votes: participantVotes } = participantData[i];
|
||||
const participantId = await nanoid();
|
||||
participants.push({
|
||||
id: participantId,
|
||||
name,
|
||||
guestId: "user-demo",
|
||||
createdAt: addMinutes(today, i * -1),
|
||||
});
|
||||
|
||||
options.forEach((option, index) => {
|
||||
votes.push({
|
||||
optionId: option.id,
|
||||
participantId,
|
||||
type: participantVotes[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const homePageUrl = absoluteUrl(req).origin;
|
||||
|
||||
await prisma.poll.create({
|
||||
data: {
|
||||
urlId: await nanoid(),
|
||||
title: "Lunch Meeting Demo",
|
||||
type: "date",
|
||||
location: "Starbucks, 901 New York Avenue",
|
||||
description: `This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to ${homePageUrl}/new to make a new poll.`,
|
||||
authorName: "Johnny",
|
||||
verified: true,
|
||||
demo: true,
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
email: demoUser.email,
|
||||
},
|
||||
create: demoUser,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
createMany: {
|
||||
data: options,
|
||||
},
|
||||
},
|
||||
links: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
role: "admin",
|
||||
urlId: adminUrlId,
|
||||
},
|
||||
{
|
||||
role: "participant",
|
||||
urlId: await nanoid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
createMany: {
|
||||
data: participants,
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
createMany: {
|
||||
data: votes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({ urlId: adminUrlId });
|
||||
}
|
||||
default:
|
||||
return res.status(405).json({ ok: 1 });
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { withSessionRoute } from "utils/auth";
|
||||
|
||||
import { prisma } from "../../db";
|
||||
|
||||
export default withSessionRoute(async (req, res) => {
|
||||
if (req.session.user?.isGuest === false) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.session.user.id },
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: user
|
||||
? { id: user.id, name: user.name, email: user.email, isGuest: false }
|
||||
: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
res.json({ user: req.session.user ?? null });
|
||||
});
|
107
pages/poll.tsx
|
@ -1,107 +0,0 @@
|
|||
import axios from "axios";
|
||||
import { GetServerSideProps, NextPage } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { withSessionSsr } from "utils/auth";
|
||||
|
||||
import ErrorPage from "@/components/error-page";
|
||||
import FullPageLoader from "@/components/full-page-loader";
|
||||
import { PollContextProvider } from "@/components/poll-context";
|
||||
import { SessionProps, withSession } from "@/components/session";
|
||||
|
||||
import { GetPollResponse } from "../api-client/get-poll";
|
||||
import Custom404 from "./404";
|
||||
|
||||
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
||||
|
||||
const PollPageLoader: NextPage<SessionProps> = () => {
|
||||
const { query } = useRouter();
|
||||
const { t } = useTranslation("app");
|
||||
const urlId = query.urlId as string;
|
||||
|
||||
const [didError, setDidError] = React.useState(false);
|
||||
const [didFailToConvertLegacyPoll, setDidFailToConvertLegacyPoll] =
|
||||
React.useState(false);
|
||||
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { data: poll } = useQuery<GetPollResponse | null>(
|
||||
["getPoll", urlId],
|
||||
async () => {
|
||||
try {
|
||||
const { data } = await axios.get<GetPollResponse>(`/api/poll/${urlId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
try {
|
||||
// check if poll exists from the legacy endpoint and convert it if it does
|
||||
const { data } = await axios.get<GetPollResponse>(
|
||||
`/api/legacy/${urlId}`,
|
||||
);
|
||||
plausible("Converted legacy event");
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 500) {
|
||||
// failed to convert legacy poll
|
||||
setDidFailToConvertLegacyPoll(true);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setDidError(true);
|
||||
},
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (didFailToConvertLegacyPoll) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="Server error"
|
||||
description="There was a problem retrieving your poll. Please contact support."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (poll) {
|
||||
return (
|
||||
<PollContextProvider value={poll}>
|
||||
<PollPage />
|
||||
</PollContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (didError) {
|
||||
return <Custom404 />;
|
||||
}
|
||||
|
||||
return <FullPageLoader>{t("loading")}</FullPageLoader>;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = withSessionSsr(
|
||||
async ({ locale = "en", req }) => {
|
||||
try {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ["app"])),
|
||||
user: req.session.user ?? null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { notFound: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default withSession(PollPageLoader);
|
|
@ -36,6 +36,7 @@
|
|||
"creatingDemo": "Creating demo poll…",
|
||||
"ok": "Ok",
|
||||
"loading": "Loading…",
|
||||
"loadingParticipants": "Loading participants…",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Full access to edit this poll.",
|
||||
"participant": "Participant",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
DATABASE_URL=postgres://your-database/db
|
||||
SECRET_PASSWORD=minimum-32-characters
|
||||
SUPPORT_EMAIL=foo@yourdomain.com
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=stack-snap
|
||||
defaults.project=rallly
|
||||
cli.executable=../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli
|
|
@ -3,7 +3,8 @@ import Cookies from "js-cookie";
|
|||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { getPortal } from "utils/selectors";
|
||||
|
||||
import { getPortal } from "@/utils/selectors";
|
||||
|
||||
import CookiesIllustration from "./cookie-consent/cookies.svg";
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
@ -4,11 +4,11 @@ import { useRouter } from "next/router";
|
|||
import { useTranslation } from "next-i18next";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSessionStorage } from "react-use";
|
||||
|
||||
import { createPoll } from "../api-client/create-poll";
|
||||
import Button from "../components/button";
|
||||
import { encodeDateOption } from "../utils/date-time-utils";
|
||||
import { trpc } from "../utils/trpc";
|
||||
import Button from "./button";
|
||||
import {
|
||||
NewEventData,
|
||||
PollDetailsData,
|
||||
|
@ -17,11 +17,10 @@ import {
|
|||
PollOptionsForm,
|
||||
UserDetailsData,
|
||||
UserDetailsForm,
|
||||
} from "../components/forms";
|
||||
import StandardLayout from "../components/standard-layout";
|
||||
import Steps from "../components/steps";
|
||||
import { encodeDateOption } from "../utils/date-time-utils";
|
||||
} from "./forms";
|
||||
import { SessionProps, useSession, withSession } from "./session";
|
||||
import StandardLayout from "./standard-layout";
|
||||
import Steps from "./steps";
|
||||
|
||||
type StepName = "eventDetails" | "options" | "userDetails";
|
||||
|
||||
|
@ -95,41 +94,23 @@ const Page: NextPage<CreatePollPageProps> = ({
|
|||
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
|
||||
useMutation(
|
||||
() => {
|
||||
const title = required(formData?.eventDetails?.title);
|
||||
return createPoll({
|
||||
title: title,
|
||||
type: "date",
|
||||
location: formData?.eventDetails?.location,
|
||||
description: formData?.eventDetails?.description,
|
||||
user: {
|
||||
name: required(formData?.userDetails?.name),
|
||||
email: required(formData?.userDetails?.contact),
|
||||
},
|
||||
timeZone: formData?.options?.timeZone,
|
||||
options: required(formData?.options?.options).map(encodeDateOption),
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (poll) => {
|
||||
setIsRedirecting(true);
|
||||
plausible("Created poll", {
|
||||
props: {
|
||||
numberOfOptions: formData.options?.options?.length,
|
||||
optionsView: formData?.options?.view,
|
||||
},
|
||||
});
|
||||
setPersistedFormData(initialNewEventData);
|
||||
router.replace(`/admin/${poll.urlId}`);
|
||||
const createPoll = trpc.useMutation(["polls.create"], {
|
||||
onSuccess: (poll) => {
|
||||
setIsRedirecting(true);
|
||||
plausible("Created poll", {
|
||||
props: {
|
||||
numberOfOptions: formData.options?.options?.length,
|
||||
optionsView: formData?.options?.view,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
setPersistedFormData(initialNewEventData);
|
||||
router.replace(`/admin/${poll.urlId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const isBusy = isRedirecting || isCreatingPoll;
|
||||
const isBusy = isRedirecting || createPoll.isLoading;
|
||||
|
||||
const handleSubmit = (
|
||||
const handleSubmit = async (
|
||||
data: PollDetailsData | PollOptionsData | UserDetailsData,
|
||||
) => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
|
@ -140,7 +121,20 @@ const Page: NextPage<CreatePollPageProps> = ({
|
|||
});
|
||||
} else {
|
||||
// last step
|
||||
createEventMutation();
|
||||
const title = required(formData?.eventDetails?.title);
|
||||
|
||||
await createPoll.mutateAsync({
|
||||
title: title,
|
||||
type: "date",
|
||||
location: formData?.eventDetails?.location,
|
||||
description: formData?.eventDetails?.description,
|
||||
user: {
|
||||
name: required(formData?.userDetails?.name),
|
||||
email: required(formData?.userDetails?.contact),
|
||||
},
|
||||
timeZone: formData?.options?.timeZone,
|
||||
options: required(formData?.options?.options).map(encodeDateOption),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
import { Comment } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { formatRelative } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
|
||||
import {
|
||||
createComment,
|
||||
CreateCommentPayload,
|
||||
} from "../../api-client/create-comment";
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import { trpc } from "../../utils/trpc";
|
||||
import Button from "../button";
|
||||
import CompactButton from "../compact-button";
|
||||
import Dropdown, { DropdownItem } from "../dropdown";
|
||||
|
@ -25,29 +19,20 @@ import { usePoll } from "../poll-context";
|
|||
import { usePreferences } from "../preferences/use-preferences";
|
||||
import { isUnclaimed, useSession } from "../session";
|
||||
|
||||
export interface DiscussionProps {
|
||||
pollId: string;
|
||||
}
|
||||
|
||||
interface CommentForm {
|
||||
authorName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
||||
pollId,
|
||||
}) => {
|
||||
const Discussion: React.VoidFunctionComponent = () => {
|
||||
const { locale } = usePreferences();
|
||||
const getCommentsQueryKey = ["poll", pollId, "comments"];
|
||||
const queryClient = useQueryClient();
|
||||
const { data: comments } = useQuery(
|
||||
getCommentsQueryKey,
|
||||
async () => {
|
||||
const res = await axios.get<{
|
||||
comments: Array<Omit<Comment, "createdAt"> & { createdAt: string }>;
|
||||
}>(`/api/poll/${pollId}/comments`);
|
||||
return res.data.comments;
|
||||
},
|
||||
const queryClient = trpc.useContext();
|
||||
const {
|
||||
poll: { pollId },
|
||||
} = usePoll();
|
||||
|
||||
const { data: comments } = trpc.useQuery(
|
||||
["polls.comments.list", { pollId }],
|
||||
{
|
||||
refetchInterval: 10000, // refetch every 10 seconds
|
||||
},
|
||||
|
@ -55,44 +40,38 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: createCommentMutation } = useMutation(
|
||||
(payload: CreateCommentPayload) => {
|
||||
// post comment
|
||||
return createComment(payload);
|
||||
const addComment = trpc.useMutation("polls.comments.add", {
|
||||
onSuccess: (newComment) => {
|
||||
session.refresh();
|
||||
queryClient.setQueryData(
|
||||
["polls.comments.list", { pollId }],
|
||||
(existingComments = []) => {
|
||||
return [...existingComments, newComment];
|
||||
},
|
||||
);
|
||||
plausible("Created comment");
|
||||
},
|
||||
{
|
||||
onSuccess: (newComment) => {
|
||||
session.refresh();
|
||||
queryClient.setQueryData(getCommentsQueryKey, (comments) => {
|
||||
if (Array.isArray(comments)) {
|
||||
return [...comments, newComment];
|
||||
}
|
||||
return [newComment];
|
||||
});
|
||||
plausible("Created comment");
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { poll } = usePoll();
|
||||
|
||||
const { mutate: deleteCommentMutation } = useMutation(
|
||||
async (payload: { pollId: string; commentId: string }) => {
|
||||
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`);
|
||||
const deleteComment = trpc.useMutation("polls.comments.delete", {
|
||||
onMutate: ({ commentId }) => {
|
||||
queryClient.setQueryData(
|
||||
["polls.comments.list", { pollId }],
|
||||
(existingComments = []) => {
|
||||
return [...existingComments].filter(({ id }) => id !== commentId);
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
onMutate: () => {
|
||||
plausible("Deleted comment");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(getCommentsQueryKey);
|
||||
},
|
||||
onSuccess: () => {
|
||||
plausible("Deleted comment");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const { register, setValue, control, handleSubmit, formState } =
|
||||
const { register, reset, control, handleSubmit, formState } =
|
||||
useForm<CommentForm>({
|
||||
defaultValues: {
|
||||
authorName: "",
|
||||
|
@ -100,13 +79,6 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
},
|
||||
});
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(commentId: string) => {
|
||||
deleteCommentMutation({ pollId, commentId });
|
||||
},
|
||||
[deleteCommentMutation, pollId],
|
||||
);
|
||||
|
||||
if (!comments) {
|
||||
return null;
|
||||
}
|
||||
|
@ -130,6 +102,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={comment.id}
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
@ -171,7 +144,10 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
icon={Trash}
|
||||
label="Delete comment"
|
||||
onClick={() => {
|
||||
handleDelete(comment.id);
|
||||
deleteComment.mutate({
|
||||
commentId: comment.id,
|
||||
pollId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
@ -188,22 +164,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
</div>
|
||||
<form
|
||||
className="bg-white p-4"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
createCommentMutation(
|
||||
{
|
||||
...data,
|
||||
pollId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setValue("content", "");
|
||||
resolve(data);
|
||||
},
|
||||
onError: reject,
|
||||
},
|
||||
);
|
||||
});
|
||||
onSubmit={handleSubmit(async ({ authorName, content }) => {
|
||||
await addComment.mutateAsync({ authorName, content, pollId });
|
||||
reset({ authorName, content: "" });
|
||||
})}
|
||||
>
|
||||
<textarea
|
|
@ -10,8 +10,9 @@ import { Menu } from "@headlessui/react";
|
|||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { transformOriginByPlacement } from "utils/constants";
|
||||
import { stopPropagation } from "utils/stop-propagation";
|
||||
|
||||
import { transformOriginByPlacement } from "@/utils/constants";
|
||||
import { stopPropagation } from "@/utils/stop-propagation";
|
||||
|
||||
const MotionMenuItems = motion(Menu.Items);
|
||||
|
|
@ -9,9 +9,9 @@ import { Listbox } from "@headlessui/react";
|
|||
import clsx from "clsx";
|
||||
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { stopPropagation } from "utils/stop-propagation";
|
||||
|
||||
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||
import { stopPropagation } from "@/utils/stop-propagation";
|
||||
|
||||
import ChevronDown from "../../../icons/chevron-down.svg";
|
||||
import { styleMenuItem } from "../../../menu-styles";
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 744 B |
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 313 B |
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
Before Width: | Height: | Size: 291 B After Width: | Height: | Size: 291 B |
Before Width: | Height: | Size: 468 B After Width: | Height: | Size: 468 B |
Before Width: | Height: | Size: 674 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 343 B |
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 354 B |
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B |
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 273 B |
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 273 B |
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 274 B |
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 273 B |
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B |