This commit is contained in:
Luke Vella 2022-05-09 08:21:53 +01:00 committed by GitHub
parent 1d7bcddf1b
commit 5c991d7011
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 2463 additions and 1178 deletions

View file

@ -1,6 +1,14 @@
{
"extends": "next/core-web-vitals",
"plugins": ["simple-import-sort"],
"extends": ["next/core-web-vitals"],
"plugins": ["simple-import-sort", "@typescript-eslint"],
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["plugin:@typescript-eslint/recommended"]
}
],
"rules": {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",

View file

@ -41,6 +41,7 @@ jobs:
- name: Set environment variables
run: |
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV
echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV
- name: Install dependencies
run: yarn install --frozen-lockfile

View file

@ -2,7 +2,6 @@
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-orange.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E)
![hero](./docs/images/hero-image.png)
Rallly is a free group meeting scheduling tool built with [Next.js](https://github.com/vercel/next.js/), [Prisma](https://github.com/prisma/prisma) & [TailwindCSS](https://github.com/tailwindlabs/tailwindcss)
@ -18,18 +17,13 @@ git clone https://github.com/lukevella/rallly.git
cd rallly
```
_optional_: Configure your SMTP server. Without this, Rallly won't be able to send out emails. You can set the following environment variables in a `.env` in the root of the project
Once inside the directory create a `.env` file where you can set your environment variables. There is a `sample.env` that you can use as a reference.
```bash
cp sample.env .env
```
# support email - used as FROM email by SMTP server
SUPPORT_EMAIL=foo@yourdomain.com
# SMTP server - required if you want to send emails
SMTP_HOST=your-smtp-server
SMTP_PORT=587
SMTP_SECURE="false"
SMTP_USER=your-smtp-user
SMTP_PWD=your-smtp-password
```
_See [configuration](#-configuration) to see what parameters are availble._
Build and run with `docker-compose`
@ -54,20 +48,7 @@ Copy the sample `.env` file then open it and set the variables.
cp sample.env .env
```
Fill in the required environment variables.
```
# postgres database - not needed if running with docker-compose
DATABASE_URL=postgres://your-database/db
# support email - used as FROM email by SMTP server
SUPPORT_EMAIL=foo@yourdomain.com
# SMTP server - required if you want to send emails
SMTP_HOST=your-smtp-server
SMTP_PORT=587
SMTP_SECURE="false"
SMTP_USER=your-smtp-user
SMTP_PWD=your-smtp-password
```
_See [configuration](#-configuration) to see what parameters are availble._
Install dependencies
@ -91,6 +72,19 @@ yarn build
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 25 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
If you would like to contribute to the development of the project please reach out first before spending significant time on it.

26
components/badge.tsx Normal file
View file

@ -0,0 +1,26 @@
import clsx from "clsx";
import React from "react";
const Badge: React.VoidFunctionComponent<{
children?: React.ReactNode;
color?: "gray" | "amber" | "green";
className?: string;
}> = ({ children, color = "gray", className }) => {
return (
<div
className={clsx(
"inline-flex h-5 items-center rounded-md px-1 text-xs",
{
"bg-slate-200 text-slate-500": color === "gray",
"bg-amber-100 text-amber-500": color === "amber",
"bg-green-100/50 text-green-500": color === "green",
},
className,
)}
>
{children}
</div>
);
};
export default Badge;

View file

@ -12,7 +12,6 @@ export interface ButtonProps {
htmlType?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
type?: "default" | "primary" | "danger" | "link";
form?: string;
href?: string;
rounded?: boolean;
title?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
@ -27,7 +26,6 @@ const Button: React.ForwardRefRenderFunction<HTMLButtonElement, ButtonProps> = (
className,
icon,
disabled,
href,
rounded,
...passThroughProps
},

View file

@ -17,7 +17,7 @@ const CompactButton: React.VoidFunctionComponent<CompactButtonProps> = ({
return (
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 active:bg-gray-300 active:text-gray-500"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-slate-100 text-slate-400 transition-colors hover:bg-slate-200 active:bg-slate-300 active:text-slate-500"
onClick={onClick}
>
{Icon ? <Icon className="h-3 w-3" /> : children}

View file

@ -20,14 +20,14 @@ import {
} from "../components/forms";
import StandardLayout from "../components/standard-layout";
import Steps from "../components/steps";
import { useUserName } from "../components/user-name-context";
import { encodeDateOption } from "../utils/date-time-utils";
import { SessionProps, useSession, withSession } from "./session";
type StepName = "eventDetails" | "options" | "userDetails";
const steps: StepName[] = ["eventDetails", "options", "userDetails"];
const required = <T extends unknown>(v: T | undefined): T => {
const required = <T,>(v: T | undefined): T => {
if (!v) {
throw new Error("Required value is missing");
}
@ -38,16 +38,25 @@ const required = <T extends unknown>(v: T | undefined): T => {
const initialNewEventData: NewEventData = { currentStep: 0 };
const sessionStorageKey = "newEventFormData";
const Page: NextPage<{
export interface CreatePollPageProps extends SessionProps {
title?: string;
location?: string;
description?: string;
view?: "week" | "month";
}> = ({ title, location, description, view }) => {
}
const Page: NextPage<CreatePollPageProps> = ({
title,
location,
description,
view,
}) => {
const { t } = useTranslation("app");
const router = useRouter();
const session = useSession();
const [persistedFormData, setPersistedFormData] =
useSessionStorage<NewEventData>(sessionStorageKey, {
currentStep: 0,
@ -59,6 +68,13 @@ const Page: NextPage<{
options: {
view,
},
userDetails:
session.user?.isGuest === false
? {
name: session.user.name,
contact: session.user.email,
}
: undefined,
});
const [formData, setTransientFormData] = React.useState(persistedFormData);
@ -77,8 +93,6 @@ const Page: NextPage<{
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [, setUserName] = useUserName();
const plausible = usePlausible();
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
@ -101,7 +115,6 @@ const Page: NextPage<{
{
onSuccess: (poll) => {
setIsRedirecting(true);
setUserName(poll.authorName);
plausible("Created poll", {
props: {
numberOfOptions: formData.options?.options?.length,
@ -220,4 +233,4 @@ const Page: NextPage<{
);
};
export default Page;
export default withSession(Page);

View file

@ -13,6 +13,7 @@ import {
CreateCommentPayload,
} from "../../api-client/create-comment";
import { requiredString } from "../../utils/form-validation";
import Badge from "../badge";
import Button from "../button";
import CompactButton from "../compact-button";
import Dropdown, { DropdownItem } from "../dropdown";
@ -20,13 +21,13 @@ import DotsHorizontal from "../icons/dots-horizontal.svg";
import Trash from "../icons/trash.svg";
import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvater from "../poll/user-avatar";
import UserAvatar from "../poll/user-avatar";
import { usePoll } from "../poll-context";
import { usePreferences } from "../preferences/use-preferences";
import { useUserName } from "../user-name-context";
import { useSession } from "../session";
export interface DiscussionProps {
pollId: string;
canDelete?: boolean;
}
interface CommentForm {
@ -36,11 +37,9 @@ interface CommentForm {
const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
pollId,
canDelete,
}) => {
const { locale } = usePreferences();
const getCommentsQueryKey = ["poll", pollId, "comments"];
const [userName, setUserName] = useUserName();
const queryClient = useQueryClient();
const { data: comments } = useQuery(
getCommentsQueryKey,
@ -64,6 +63,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
},
{
onSuccess: (newComment) => {
session.refresh();
queryClient.setQueryData(getCommentsQueryKey, (comments) => {
if (Array.isArray(comments)) {
return [...comments, newComment];
@ -75,6 +75,8 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
},
);
const { poll } = usePoll();
const { mutate: deleteCommentMutation } = useMutation(
async (payload: { pollId: string; commentId: string }) => {
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`);
@ -89,18 +91,16 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
},
);
const session = useSession();
const { register, setValue, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName: userName,
authorName: "",
content: "",
},
});
React.useEffect(() => {
setValue("authorName", userName);
}, [setValue, userName]);
const handleDelete = React.useCallback(
(commentId: string) => {
deleteCommentMutation({ pollId, commentId });
@ -124,6 +124,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
>
<AnimatePresence initial={false}>
{comments.map((comment) => {
const canDelete =
poll.role === "admin" || session.ownsObject(comment);
return (
<motion.div
transition={{ duration: 0.2 }}
@ -137,12 +140,16 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
initial={{ scale: 0.8, y: 10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.8 }}
data-testid="comment"
className="w-fit rounded-xl border bg-white px-3 py-2 shadow-sm"
>
<div className="flex items-center space-x-2">
<UserAvater name={comment.authorName} />
<UserAvatar
name={comment.authorName}
showName={true}
isYou={session.ownsObject(comment)}
/>
<div className="mb-1">
<span className="mr-1">{comment.authorName}</span>
<span className="mr-1 text-slate-400">&bull;</span>
<span className="text-sm text-slate-500">
{formatRelative(
@ -189,7 +196,6 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
},
{
onSuccess: () => {
setUserName(data.authorName);
setValue("content", "");
resolve(data);
},
@ -201,23 +207,28 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
>
<textarea
id="comment"
placeholder="Add your comment…"
placeholder="Thanks for the invite!"
className="input w-full py-2 pl-3 pr-4"
{...register("content", { validate: requiredString })}
/>
<div className="mt-1 flex space-x-3">
<div>
<Controller
name="authorName"
key={session.user?.id}
control={control}
rules={{ validate: requiredString }}
render={({ field }) => <NameInput className="w-full" {...field} />}
render={({ field }) => (
<NameInput {...field} className="w-full" />
)}
/>
</div>
<Button
htmlType="submit"
loading={formState.isSubmitting}
type="primary"
>
Send
Comment
</Button>
</div>
</form>

View file

@ -1,4 +1,5 @@
import {
autoUpdate,
flip,
FloatingPortal,
offset,
@ -27,22 +28,28 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
trigger,
placement: preferredPlacement,
}) => {
const { reference, floating, x, y, strategy, placement } = useFloating({
const { reference, floating, x, y, strategy, placement, refs, update } =
useFloating({
strategy: "fixed",
placement: preferredPlacement,
middleware: [offset(5), flip()],
});
const animationOrigin = transformOriginByPlacement[placement];
React.useEffect(() => {
if (!refs.reference.current || !refs.floating.current) {
return;
}
// Only call this when the floating element is rendered
return autoUpdate(refs.reference.current, refs.floating.current, update);
}, [refs.reference, refs.floating, update]);
return (
<Menu>
{({ open }) => (
<>
<Menu.Button
as="div"
className={clsx("inline-block", className)}
ref={reference}
>
<Menu.Button as="div" className={className} ref={reference}>
{trigger}
</Menu.Button>
<FloatingPortal>

View file

@ -7,7 +7,6 @@ import Chat from "@/components/icons/chat.svg";
import EmojiSad from "@/components/icons/emoji-sad.svg";
import { showCrispChat } from "./crisp-chat";
import StandardLayout from "./standard-layout";
export interface ComponentProps {
icon?: React.ComponentType<{ className?: string }>;
@ -21,8 +20,7 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
description,
}) => {
return (
<StandardLayout>
<div className="flex h-full max-w-full items-center justify-center bg-gray-50 px-4 py-8 lg:w-[1024px]">
<div className="mx-auto flex h-full max-w-full items-center justify-center bg-gray-50 px-4 py-8 lg:w-[1024px]">
<Head>
<title>{title}</title>
<meta name="robots" content="noindex,nofollow" />
@ -45,7 +43,6 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
</div>
</div>
</div>
</StandardLayout>
);
};

View file

@ -110,7 +110,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
<div
// onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
onMouseUp={props.onClick}
className="absolute ml-1 max-h-full cursor-pointer overflow-hidden rounded-md bg-green-100 bg-opacity-80 p-1 text-xs text-green-500 transition-colors hover:bg-opacity-50"
className="absolute ml-1 max-h-full overflow-hidden rounded-md bg-green-100 bg-opacity-80 p-1 text-xs text-green-500 transition-colors hover:bg-opacity-50"
style={{
top: `calc(${props.style?.top}% + 4px)`,
height: `calc(${props.style?.height}% - 8px)`,
@ -126,6 +126,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
},
week: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: ({ date }: any) => {
const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => {

View file

@ -8,6 +8,7 @@ export interface NewEventData {
options?: Partial<PollOptionsData>;
userDetails?: Partial<UserDetailsData>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface PollFormProps<T extends Record<string, any>> {
onSubmit: (data: T) => void;
onChange?: (data: Partial<T>) => void;

View file

@ -3,7 +3,7 @@ import { useTranslation } from "next-i18next";
import * as React from "react";
import { useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation";
import { requiredString, validEmail } from "../../utils/form-validation";
import { PollFormProps } from "./types";
export interface UserDetailsData {
@ -65,9 +65,7 @@ export const UserDetailsForm: React.VoidFunctionComponent<
})}
placeholder={t("emailPlaceholder")}
{...register("contact", {
validate: (value) => {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
},
validate: validEmail,
})}
/>
</div>

View file

@ -1,19 +0,0 @@
import { useTranslation } from "next-i18next";
import React from "react";
const Header: React.FunctionComponent<{ className?: string }> = (props) => {
const { t } = useTranslation();
return (
<div
className="flex h-10 shrink-0 items-center border-gray-200 px-6"
{...props}
>
<div className="text-base font-bold uppercase text-gray-400">
{t("appName")}
</div>
<div className="ml-2 text-xs text-gray-400">v2-alpha</div>
</div>
);
};
export default Header;

View file

@ -5,7 +5,7 @@ import { useTimeoutFn } from "react-use";
import DateCard from "../date-card";
import Score from "../poll/desktop-poll/score";
import UserAvater from "../poll/user-avatar";
import UserAvatar from "../poll/user-avatar";
import VoteIcon from "../poll/vote-icon";
const sidebarWidth = 180;
@ -87,14 +87,11 @@ const PollDemo: React.VoidFunctionComponent = () => {
className="flex shrink-0 items-center px-4"
style={{ width: sidebarWidth }}
>
<UserAvater
className="mr-2"
<UserAvatar
color={participant.color}
name={participant.name}
showName={true}
/>
<span className="truncate" title={participant.name}>
{participant.name}
</span>
</div>
<div className="flex">
{options.map((_, i) => {

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>

Before

Width:  |  Height:  |  Size: 459 B

After

Width:  |  Height:  |  Size: 264 B

Before After
Before After

73
components/login-form.tsx Normal file
View file

@ -0,0 +1,73 @@
import axios from "axios";
import clsx from "clsx";
import { useRouter } from "next/router";
import * as React from "react";
import { useForm } from "react-hook-form";
import { validEmail } from "utils/form-validation";
import Button from "@/components/button";
import Magic from "@/components/icons/magic.svg";
const LoginForm: React.VoidFunctionComponent = () => {
const { register, formState, handleSubmit, getValues } =
useForm<{ email: string }>();
const router = useRouter();
return (
<div className="flex">
<div className="hidden items-center rounded-tl-lg rounded-bl-lg bg-slate-50 p-6 md:flex">
<Magic className="h-24 text-slate-300" />
</div>
<div className="max-w-sm p-6">
<div className="mb-2 text-xl font-semibold">Login via magic link</div>
{!formState.isSubmitSuccessful ? (
<form
onSubmit={handleSubmit(async ({ email }) => {
await axios.post("/api/login", { email, path: router.asPath });
})}
>
<div className="mb-2 text-slate-500">
We&apos;ll send you an email with a magic link that you can use to
login.
</div>
<div className="mb-4">
<input
autoFocus={true}
readOnly={formState.isSubmitting}
className={clsx("input w-full", {
"input-error": formState.errors.email,
})}
placeholder="john.doe@email.com"
{...register("email", { validate: validEmail })}
/>
{formState.errors.email ? (
<div className="mt-1 text-sm text-rose-500">
Please enter a valid email address
</div>
) : null}
</div>
<div className="flex space-x-3">
<Button
htmlType="submit"
loading={formState.isSubmitting}
type="primary"
>
Send me a magic link
</Button>
</div>
</form>
) : (
<div>
<div className="text-slate-500">A magic link has been sent to:</div>
<div className="font-mono text-indigo-500">
{getValues("email")}
</div>
<div className="mt-2 text-slate-500">Please check you inbox.</div>
</div>
)}
</div>
</div>
);
};
export default LoginForm;

View file

@ -2,6 +2,8 @@ import { Dialog } from "@headlessui/react";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import X from "@/components/icons/x.svg";
import Button, { ButtonProps } from "../button";
export interface ModalProps {
@ -16,6 +18,7 @@ export interface ModalProps {
content?: React.ReactNode;
overlayClosable?: boolean;
visible?: boolean;
showClose?: boolean;
}
const Modal: React.VoidFunctionComponent<ModalProps> = ({
@ -30,6 +33,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
onCancel,
onOk,
visible,
showClose,
}) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return (
@ -53,28 +57,42 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-0 bg-slate-900 bg-opacity-10"
className="fixed inset-0 z-0 bg-slate-900 bg-opacity-25"
/>
<motion.div
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="z-50 my-8 mx-4 inline-block w-fit transform overflow-hidden rounded-xl bg-white text-left align-middle shadow-xl transition-all"
className="relative z-50 my-8 inline-block max-w-full transform text-left align-middle"
>
<div className="mx-4 max-w-full overflow-hidden rounded-xl bg-white shadow-xl xs:rounded-xl">
{showClose ? (
<button
className="absolute right-5 top-1 z-10 rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 active:bg-slate-500/20"
onClick={onCancel}
>
<X className="h-4" />
</button>
) : null}
{content ?? (
<div className="max-w-lg p-4">
{title ? <Dialog.Title>{title}</Dialog.Title> : null}
<div className="max-w-md p-6">
{title ? (
<Dialog.Title className="mb-2 font-medium">
{title}
</Dialog.Title>
) : null}
{description ? (
<Dialog.Description>{description}</Dialog.Description>
<Dialog.Description className="m-0">
{description}
</Dialog.Description>
) : null}
</div>
)}
{footer ?? (
<div className="flex h-14 items-center justify-end space-x-3 border-t bg-slate-50 px-4">
{footer === undefined ? (
<div className="flex h-14 items-center justify-end space-x-3 rounded-br-lg rounded-bl-lg border-t bg-slate-50 px-4">
{cancelText ? (
<Button
ref={initialFocusRef}
onClick={() => {
onCancel?.();
}}
@ -84,6 +102,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
) : null}
{okText ? (
<Button
ref={initialFocusRef}
type="primary"
onClick={() => {
onOk?.();
@ -94,7 +113,8 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
</Button>
) : null}
</div>
)}
) : null}
</div>
</motion.div>
</motion.div>
</Dialog>

View file

@ -1,7 +1,7 @@
import clsx from "clsx";
import * as React from "react";
import UserAvater from "./poll/user-avatar";
import UserAvatar from "./poll/user-avatar";
interface NameInputProps
extends React.DetailedHTMLProps<
@ -18,7 +18,7 @@ const NameInput: React.ForwardRefRenderFunction<
> = ({ value, defaultValue, className, ...forwardProps }, ref) => {
return (
<div className="relative flex items-center">
<UserAvater
<UserAvatar
name={value ?? defaultValue ?? ""}
className="absolute left-2"
/>

View file

@ -10,11 +10,13 @@ import {
} from "utils/date-time-utils";
import { usePreferences } from "./preferences/use-preferences";
import { useSession } from "./session";
import { useRequiredContext } from "./use-required-context";
type VoteType = "yes" | "no";
type PollContextValue = {
userAlreadyVoted: boolean;
poll: GetPollResponse;
targetTimeZone: string;
setTargetTimeZone: (timeZone: string) => void;
@ -43,6 +45,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
value: GetPollResponse;
children?: React.ReactNode;
}> = ({ value: poll, children }) => {
const { user } = useSession();
const [targetTimeZone, setTargetTimeZone] =
React.useState(getBrowserTimeZone);
@ -85,7 +88,16 @@ export const PollContextProvider: React.VoidFunctionComponent<{
return participant;
};
const userAlreadyVoted = user
? poll.participants.some((participant) =>
user.isGuest
? participant.guestId === user.id
: participant.userId === user.id,
)
: false;
return {
userAlreadyVoted,
poll,
getVotesForOption: (optionId: string) => {
// TODO (Luke Vella) [2022-04-16]: Build an index instead
@ -109,7 +121,14 @@ export const PollContextProvider: React.VoidFunctionComponent<{
targetTimeZone,
setTargetTimeZone,
};
}, [locale, participantById, participantsByOptionId, poll, targetTimeZone]);
}, [
locale,
participantById,
participantsByOptionId,
poll,
targetTimeZone,
user,
]);
return (
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
);

View file

@ -23,9 +23,9 @@ import TruncatedLinkify from "./poll/truncated-linkify";
import { UserAvatarProvider } from "./poll/user-avatar";
import { PollContextProvider, usePoll } from "./poll-context";
import Popover from "./popover";
import { useSession } from "./session";
import Sharing from "./sharing";
import StandardLayout from "./standard-layout";
import { useUserName } from "./user-name-context";
const Discussion = React.lazy(() => import("@/components/discussion"));
@ -45,7 +45,7 @@ const PollInner: NextPage = () => {
}
});
const [, setUserName] = useUserName();
const session = useSession();
const queryClient = useQueryClient();
const plausible = usePlausible();
@ -61,15 +61,20 @@ const PollInner: NextPage = () => {
{
onSuccess: () => {
toast.success("Your poll has been verified");
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
queryClient.setQueryData(["getPoll", poll.urlId], {
...poll,
verified: true,
});
session.refresh();
plausible("Verified email");
setUserName(poll.authorName);
},
onSettled: () => {
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
},
onError: () => {
toast.error("Your link has expired or is no longer valid");
},
},
);
@ -209,10 +214,7 @@ const PollInner: NextPage = () => {
<div className="mb-4 lg:mb-8">
<PollComponent pollId={poll.urlId} highScore={highScore} />
</div>
<Discussion
pollId={poll.urlId}
canDelete={poll.role === "admin"}
/>
<Discussion pollId={poll.urlId} />
</React.Suspense>
</div>
</div>

View file

@ -28,15 +28,16 @@ const minSidebarWidth = 180;
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
const { t } = useTranslation("app");
const { poll, targetTimeZone, setTargetTimeZone, options } = usePoll();
const { poll, targetTimeZone, setTargetTimeZone, options, userAlreadyVoted } =
usePoll();
const { timeZone, participants, role } = poll;
const { timeZone, participants } = poll;
const [ref, { width }] = useMeasure<HTMLDivElement>();
const [editingParticipantId, setEditingParticipantId] =
React.useState<string | null>(null);
const actionColumnWidth = 160;
const actionColumnWidth = 140;
const columnWidth = Math.min(
100,
Math.max(
@ -71,7 +72,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
const [didUsePagination, setDidUsePagination] = React.useState(false);
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(participants.length === 0);
React.useState(!userAlreadyVoted);
const pollWidth =
sidebarWidth + options.length * columnWidth + actionColumnWidth;
@ -115,7 +116,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
ref={ref}
>
<div className=" border-t border-b bg-white shadow-sm md:rounded-lg md:border">
<div className="sticky top-0 z-10 rounded-t-lg border-b border-gray-200 bg-white/80 shadow-sm shadow-slate-50 backdrop-blur-md">
<div className="sticky top-12 z-10 rounded-t-lg border-b border-gray-200 bg-white/80 shadow-sm shadow-slate-50 backdrop-blur-md lg:top-0">
<div className="flex h-14 items-center justify-end space-x-4 rounded-t-lg border-b bg-gray-50 px-4">
{timeZone ? (
<div className="flex grow items-center">
@ -226,7 +227,6 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
key={i}
participant={participant}
options={poll.options}
canDelete={role === "admin"}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
setEditingParticipantId(isEditing ? participant.id : null);

View file

@ -3,12 +3,13 @@ import clsx from "clsx";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import CheckCircle from "@/components/icons/check-circle.svg";
import CompactButton from "@/components/compact-button";
import X from "@/components/icons/x.svg";
import { useSession } from "@/components/session";
import { requiredString } from "../../../utils/form-validation";
import Button from "../../button";
import NameInput from "../../name-input";
import Tooltip from "../../tooltip";
import { ParticipantForm } from "../types";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
@ -34,6 +35,8 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
setScrollPosition,
} = usePollContext();
const session = useSession();
const {
handleSubmit,
register,
@ -41,9 +44,21 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
formState: { errors, submitCount, isSubmitting },
reset,
} = useForm<ParticipantForm>({
defaultValues: { name: "", votes: [], ...defaultValues },
defaultValues: {
name: "",
votes: [],
...defaultValues,
},
});
React.useEffect(() => {
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
onCancel?.();
}
});
}, [onCancel]);
const isColumnVisible = (index: number) => {
return (
scrollPosition + numberOfColumns * columnWidth > columnWidth * index
@ -87,7 +102,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
render={({ field }) => (
<div className="w-full">
<NameInput
autoFocus={true}
autoFocus={!session.user}
className={clsx("w-full", {
"input-error animate-wiggle":
errors.name && submitCount > 0,
@ -162,18 +177,15 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
})}
</ControlledScrollArea>
<div className="flex items-center space-x-2 px-2 transition-all">
<Tooltip content="Save" placement="top">
<Button
htmlType="submit"
icon={<CheckCircle />}
type="primary"
loading={isSubmitting}
data-testid="submitNewParticipant"
/>
</Tooltip>
<Button onClick={onCancel} type="default">
Cancel
>
Save
</Button>
<CompactButton onClick={onCancel} icon={X} />
</div>
</form>
);

View file

@ -2,14 +2,16 @@ import { Option, Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import * as React from "react";
import Button from "@/components/button";
import Pencil from "@/components/icons/pencil.svg";
import Badge from "@/components/badge";
import CompactButton from "@/components/compact-button";
import Pencil from "@/components/icons/pencil-alt.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { useSession } from "@/components/session";
import { useUpdateParticipantMutation } from "../mutations";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
import UserAvater from "../user-avatar";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
import ControlledScrollArea from "./controlled-scroll-area";
import ParticipantRowForm from "./participant-row-form";
@ -20,7 +22,6 @@ export interface ParticipantRowProps {
participant: Participant & { votes: Vote[] };
options: Array<Option & { votes: Vote[] }>;
editMode: boolean;
canDelete?: boolean;
onChangeEditMode?: (value: boolean) => void;
}
@ -29,23 +30,26 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
participant,
options,
editMode,
canDelete,
onChangeEditMode,
}) => {
const {
setActiveOptionId,
activeOptionId,
columnWidth,
sidebarWidth,
actionColumnWidth,
} = usePollContext();
const { setActiveOptionId, activeOptionId, columnWidth, sidebarWidth } =
usePollContext();
const { mutate: updateParticipantMutation } =
useUpdateParticipantMutation(urlId);
const confirmDeleteParticipant = useDeleteParticipantModal();
const session = useSession();
const { poll } = usePoll();
const isYou = session.user && session.ownsObject(participant) ? true : false;
const isAnonymous = !participant.userId && !participant.guestId;
const canEdit =
!poll.closed && (poll.role === "admin" || isYou || isAnonymous);
if (editMode) {
return (
<ParticipantRowForm
@ -81,16 +85,35 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
return (
<div
key={participant.id}
data-testid="participant-row"
className="group flex h-14 transition-colors hover:bg-slate-50"
>
<div
className="flex shrink-0 items-center px-4"
style={{ width: sidebarWidth }}
>
<UserAvater className="mr-2" name={participant.name} />
<span className="truncate" title={participant.name}>
{participant.name}
</span>
<UserAvatar
className="mr-2"
name={participant.name}
showName={true}
isYou={isYou}
/>
{canEdit ? (
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden px-2 group-hover:flex">
<CompactButton
icon={Pencil}
onClick={() => {
onChangeEditMode?.(true);
}}
/>
<CompactButton
icon={Trash}
onClick={() => {
confirmDeleteParticipant(participant.id);
}}
/>
</div>
) : null}
</div>
<ControlledScrollArea>
{options.map((option) => {
@ -118,30 +141,6 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
);
})}
</ControlledScrollArea>
{!poll.closed ? (
<div
style={{ width: actionColumnWidth }}
className="flex items-center space-x-2 overflow-hidden px-2 opacity-0 transition-all delay-100 group-hover:opacity-100"
>
<Button
icon={<Pencil />}
onClick={() => {
onChangeEditMode?.(true);
}}
>
Edit
</Button>
{canDelete ? (
<Button
icon={<Trash />}
type="danger"
onClick={() => {
confirmDeleteParticipant(participant.id);
}}
/>
) : null}
</div>
) : null}
</div>
);
};

View file

@ -20,7 +20,7 @@ const Score: React.VoidFunctionComponent<ScoreProps> = ({
" z-20 flex h-5 w-5 items-center justify-center rounded-full text-xs shadow-sm shadow-slate-200 transition-colors",
{
"bg-rose-500 text-white": highlight,
"bg-slate-200 text-slate-500": !highlight,
"bg-gray-200 text-gray-500": !highlight,
},
className,
)}

View file

@ -216,7 +216,7 @@ const ManagePoll: React.VoidFunctionComponent<{
)}`;
const encodedCsv = encodeURI(csv);
var link = document.createElement("a");
const link = document.createElement("a");
link.setAttribute("href", encodedCsv);
link.setAttribute(
"download",

View file

@ -8,18 +8,19 @@ import { Controller, FormProvider, useForm } from "react-hook-form";
import smoothscroll from "smoothscroll-polyfill";
import ChevronDown from "@/components/icons/chevron-down.svg";
import Pencil from "@/components/icons/pencil.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import PlusCircle from "@/components/icons/plus-circle.svg";
import Save from "@/components/icons/save.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { requiredString } from "../../utils/form-validation";
import Badge from "../badge";
import Button from "../button";
import { styleMenuItem } from "../menu-styles";
import NameInput from "../name-input";
import { useSession } from "../session";
import TimeZonePicker from "../time-zone-picker";
import { useUserName } from "../user-name-context";
import PollOptions from "./mobile-poll/poll-options";
import TimeSlotOptions from "./mobile-poll/time-slot-options";
import {
@ -28,7 +29,7 @@ import {
} from "./mutations";
import { ParticipantForm, PollProps } from "./types";
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
import UserAvater from "./user-avatar";
import UserAvatar from "./user-avatar";
if (typeof window !== "undefined") {
smoothscroll.polyfill();
@ -41,8 +42,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
const { timeZone, participants, role } = poll;
const [, setUserName] = useUserName();
const participantById = participants.reduce<
Record<string, Participant & { votes: Vote[] }>
>((acc, curr) => {
@ -50,6 +49,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
return acc;
}, {});
const session = useSession();
const form = useForm<ParticipantForm>({
defaultValues: {
name: "",
@ -65,9 +66,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
? participantById[selectedParticipantId]
: undefined;
const [editable, setEditable] = React.useState(() =>
participants.length > 0 ? false : true,
);
const [editable, setEditable] = React.useState(false);
const [shouldShowSaveButton, setShouldShowSaveButton] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
@ -148,30 +147,34 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
});
})}
>
<div className="sticky top-0 z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
<div className="sticky top-12 z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
<div className="flex space-x-3">
<Listbox
value={selectedParticipantId}
onChange={setSelectedParticipantId}
disabled={editable}
>
<div className="menu grow">
<div className="menu min-w-0 grow">
<Listbox.Button
className={clsx("btn-default w-full px-2 text-left", {
"btn-disabled": editable,
})}
as={Button}
className="w-full"
disabled={!editable}
data-testid="participant-selector"
>
<div className="grow">
<div className="min-w-0 grow text-left">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
<UserAvater name={selectedParticipant.name} />
<span>{selectedParticipant.name}</span>
<UserAvatar
name={selectedParticipant.name}
showName={true}
isYou={session.ownsObject(selectedParticipant)}
/>
</div>
) : (
t("participantCount", { count: participants.length })
)}
</div>
<ChevronDown className="h-5" />
<ChevronDown className="h-5 shrink-0" />
</Listbox.Button>
<Listbox.Options
as={motion.div}
@ -192,8 +195,11 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvater name={participant.name} />
<span>{participant.name}</span>
<UserAvatar
name={participant.name}
showName={true}
isYou={session.ownsObject(participant)}
/>
</div>
</Listbox.Option>
))}
@ -201,7 +207,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
</div>
</Listbox>
{!poll.closed && !editable ? (
selectedParticipant ? (
selectedParticipant &&
(role === "admin" || session.ownsObject(selectedParticipant)) ? (
<div className="flex space-x-3">
<Button
icon={<Pencil />}
@ -217,7 +224,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
>
Edit
</Button>
{role === "admin" ? (
<Button
icon={<Trash />}
data-testid="delete-participant-button"
@ -228,7 +234,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
}
}}
/>
) : null}
</div>
) : (
<Button
@ -236,7 +241,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
icon={<PlusCircle />}
onClick={() => {
reset({ name: "", votes: [] });
setUserName("");
setEditable(true);
}}
>
@ -325,6 +329,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
className="space-y-3 border-t bg-gray-50 p-3"
>
<div className="flex space-x-3">
<div className="grow">
<Controller
name="name"
control={control}
@ -339,8 +344,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
/>
)}
/>
</div>
<Button
className="grow"
icon={<Save />}
htmlType="submit"
type="primary"

View file

@ -3,7 +3,7 @@ import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import UserAvater from "../user-avatar";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
import PopularityScore from "./popularity-score";
@ -106,7 +106,7 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
.slice(0, participants.length <= 6 ? 6 : 5)
.map((participant, i) => {
return (
<UserAvater
<UserAvatar
key={i}
className="ring-1 ring-white"
name={participant.name}

View file

@ -24,7 +24,7 @@ const TimeSlotOptions: React.VoidFunctionComponent<TimeSlotOptionsProps> = ({
{Object.entries(grouped).map(([day, options]) => {
return (
<div key={day}>
<div className="sticky top-[105px] z-10 flex border-b bg-gray-50/80 py-2 px-4 text-sm font-semibold shadow-sm backdrop-blur-md">
<div className="sticky top-[152px] z-10 flex border-b bg-gray-50/80 py-2 px-4 text-sm font-semibold shadow-sm backdrop-blur-md">
{day}
</div>
<PollOptions

View file

@ -13,12 +13,12 @@ import {
UpdateParticipantPayload,
} from "../../api-client/update-participant";
import { usePoll } from "../poll-context";
import { useUserName } from "../user-name-context";
import { useSession } from "../session";
import { ParticipantForm } from "./types";
export const useAddParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const [, setUserName] = useUserName();
const session = useSession();
const plausible = usePlausible();
return useMutation(
(payload: ParticipantForm) =>
@ -28,9 +28,8 @@ export const useAddParticipantMutation = (pollId: string) => {
votes: payload.votes,
}),
{
onSuccess: (participant, { name }) => {
onSuccess: (participant) => {
plausible("Add participant");
setUserName(name);
queryClient.setQueryData<GetPollResponse>(
["getPoll", pollId],
(poll) => {
@ -52,6 +51,7 @@ export const useAddParticipantMutation = (pollId: string) => {
return poll;
},
);
session.refresh();
},
},
);
@ -59,8 +59,8 @@ export const useAddParticipantMutation = (pollId: string) => {
export const useUpdateParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const [, setUserName] = useUserName();
const plausible = usePlausible();
return useMutation(
(payload: UpdateParticipantPayload) =>
updateParticipant({
@ -70,9 +70,6 @@ export const useUpdateParticipantMutation = (pollId: string) => {
votes: payload.votes,
}),
{
onMutate: ({ name }) => {
setUserName(name);
},
onSuccess: () => {
plausible("Update participant");
},

View file

@ -10,10 +10,7 @@ import { usePoll } from "../poll-context";
import Tooltip from "../tooltip";
import { useUpdatePollMutation } from "./mutations";
export interface NotificationsToggleProps {}
const NotificationsToggle: React.VoidFunctionComponent<NotificationsToggleProps> =
() => {
const NotificationsToggle: React.VoidFunctionComponent = () => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
@ -56,9 +53,7 @@ const NotificationsToggle: React.VoidFunctionComponent<NotificationsToggleProps>
>
<Button
loading={isUpdatingNotifications}
icon={
poll.verified && poll.notifications ? <Bell /> : <BellCrossed />
}
icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
disabled={!poll.verified}
onClick={() => {
setIsUpdatingNotifications(true);
@ -81,6 +76,6 @@ const NotificationsToggle: React.VoidFunctionComponent<NotificationsToggleProps>
/>
</Tooltip>
);
};
};
export default NotificationsToggle;

View file

@ -4,15 +4,14 @@ import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { useMutation } from "react-query";
import Badge from "../badge";
import Button from "../button";
import { usePoll } from "../poll-context";
import Popover from "../popover";
import { usePreferences } from "../preferences/use-preferences";
import Tooltip from "../tooltip";
export interface PollSubheaderProps {}
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
const PollSubheader: React.VoidFunctionComponent = () => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const { locale } = usePreferences();
@ -40,9 +39,7 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
<span className="inline-flex items-center space-x-1">
{poll.role === "admin" ? (
poll.verified ? (
<span className="inline-flex h-5 cursor-default items-center rounded-md bg-green-100/50 px-1 text-xs text-green-500 transition-colors">
Verified
</span>
<Badge color="green">Verified</Badge>
) : (
<Popover
trigger={
@ -87,9 +84,7 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
width={400}
content="This poll was created with an older version of Rallly. Some features might not work."
>
<span className="inline-flex h-5 cursor-default items-center rounded-md bg-amber-100 px-1 text-xs text-amber-500">
Legacy
</span>
<Badge color="amber">Legacy</Badge>
</Tooltip>
) : null}
</span>

View file

@ -2,11 +2,15 @@ import clsx from "clsx";
import * as React from "react";
import { stringToValue } from "utils/string-to-value";
import Badge from "../badge";
export interface UserAvaterProps {
name: string;
className?: string;
size?: "default" | "large";
color?: string;
showName?: boolean;
isYou?: boolean;
}
const UserAvatarContext =
@ -68,7 +72,7 @@ export const UserAvatarProvider: React.VoidFunctionComponent<{
);
};
const UserAvater: React.VoidFunctionComponent<UserAvaterProps> = ({
const UserAvatarInner: React.VoidFunctionComponent<UserAvaterProps> = ({
name,
className,
color: colorOverride,
@ -101,4 +105,30 @@ const UserAvater: React.VoidFunctionComponent<UserAvaterProps> = ({
);
};
export default UserAvater;
const UserAvatar: React.VoidFunctionComponent<UserAvaterProps> = ({
showName,
isYou,
className,
...forwardedProps
}) => {
if (!showName) {
return <UserAvatarInner className={className} {...forwardedProps} />;
}
return (
<div
className={clsx(
"inline-flex items-center space-x-2 overflow-hidden",
className,
)}
>
<UserAvatarInner {...forwardedProps} />
<div className="min-w-0 truncate" title={forwardedProps.name}>
{forwardedProps.name}
</div>
{isYou ? <Badge>You</Badge> : null}
</div>
);
};
export default UserAvatar;

View file

@ -13,7 +13,7 @@ import React from "react";
import { transformOriginByPlacement } from "utils/constants";
interface PopoverProps {
trigger: React.ReactNode;
trigger: React.ReactElement;
children?: React.ReactNode;
placement?: Placement;
}
@ -37,11 +37,7 @@ const Popover: React.VoidFunctionComponent<PopoverProps> = ({
<HeadlessPopover as={React.Fragment}>
{({ open }) => (
<>
<HeadlessPopover.Button
ref={reference}
as="div"
className={clsx("inline-block")}
>
<HeadlessPopover.Button ref={reference} as="div">
{trigger}
</HeadlessPopover.Button>
<FloatingPortal>

103
components/session.tsx Normal file
View file

@ -0,0 +1,103 @@
import axios from "axios";
import { IronSessionData } from "iron-session";
import React from "react";
import { useQuery, useQueryClient } from "react-query";
import { useRequiredContext } from "./use-required-context";
export type UserSessionData = NonNullable<IronSessionData["user"]>;
export type SessionProps = {
user: UserSessionData | null;
};
type SessionContextValue = {
logout: () => Promise<void>;
user: (UserSessionData & { shortName: string }) | null;
refresh: () => void;
ownsObject: (obj: {
userId: string | null;
guestId: string | null;
}) => boolean;
isLoading: boolean;
};
export const SessionContext =
React.createContext<SessionContextValue | null>(null);
SessionContext.displayName = "SessionContext";
export const SessionProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
session: UserSessionData | null;
}> = ({ children, session }) => {
const queryClient = useQueryClient();
const {
data: user = session,
refetch,
isLoading,
} = useQuery(["user"], async () => {
const res = await axios.get<{ user: UserSessionData | null }>("/api/user");
return res.data.user;
});
const sessionData: SessionContextValue = {
user: user
? {
...user,
shortName:
// try to get the first name in the event
// that the user entered a full name
user.isGuest
? user.id.substring(0, 12)
: user.name.length > 12 && user.name.indexOf(" ") !== -1
? user.name.substring(0, user.name.indexOf(" "))
: user.name,
}
: null,
refresh: () => {
refetch();
},
isLoading,
logout: async () => {
queryClient.setQueryData(["user"], null);
await axios.post("/api/logout");
},
ownsObject: (obj) => {
if (!user) {
return false;
}
if (user.isGuest) {
return obj.guestId === user.id;
}
return obj.userId === user.id;
},
};
return (
<SessionContext.Provider value={sessionData}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => {
return useRequiredContext(SessionContext);
};
export const withSession = <P extends SessionProps>(
component: React.ComponentType<P>,
) => {
const ComposedComponent: React.VoidFunctionComponent<P> = (props: P) => {
const Component = component;
return (
<SessionProvider session={props.user}>
<Component {...props} />
</SessionProvider>
);
};
ComposedComponent.displayName = component.displayName;
return ComposedComponent;
};

View file

@ -1,29 +1,122 @@
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import React from "react";
import Menu from "@/components/icons/menu.svg";
import User from "@/components/icons/user.svg";
import Logo from "../public/logo.svg";
import Dropdown, { DropdownItem, DropdownProps } from "./dropdown";
import Adjustments from "./icons/adjustments.svg";
import Cash from "./icons/cash.svg";
import DotsVertical from "./icons/dots-vertical.svg";
import Github from "./icons/github.svg";
import Login from "./icons/login.svg";
import Logout from "./icons/logout.svg";
import Pencil from "./icons/pencil.svg";
import Question from "./icons/question-mark-circle.svg";
import Support from "./icons/support.svg";
import Twitter from "./icons/twitter.svg";
import LoginForm from "./login-form";
import { useModal } from "./modal";
import { useModalContext } from "./modal/modal-provider";
import Popover from "./popover";
import Preferences from "./preferences";
import { useSession } from "./session";
const HomeLink = () => {
return (
<Link href="/">
<a>
<Logo className="w-28 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600" />
<Logo className="w-28 text-indigo-500 transition-colors active:text-indigo-600 lg:w-32" />
</a>
</Link>
);
};
const MobileNavigation: React.VoidFunctionComponent<{
openLoginModal: () => void;
}> = ({ openLoginModal }) => {
const { user } = useSession();
return (
<div className="fixed top-0 z-30 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 shadow-sm lg:hidden">
<div>
<HomeLink />
</div>
<div className="flex items-center">
{user ? null : (
<button
onClick={openLoginModal}
className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Login className="h-5 opacity-75" />
<span className="inline-block">Login</span>
</button>
)}
<AnimatePresence initial={false}>
{user ? (
<UserDropdown
openLoginModal={openLoginModal}
placement="bottom-end"
trigger={
<motion.button
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{
y: -50,
opacity: 0,
}}
data-testid="user"
className="group inline-flex w-full items-center space-x-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20"
>
<div className="relative shrink-0">
{user.isGuest ? (
<span className="absolute right-0 top-0 h-1 w-1 rounded-full bg-indigo-500" />
) : null}
<User className="w-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
</div>
<div className="hidden max-w-[120px] truncate font-medium xs:block">
{user.shortName}
</div>
</motion.button>
}
/>
) : null}
</AnimatePresence>
<Popover
placement="bottom-end"
trigger={
<button
type="button"
className="group flex items-center whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Adjustments className="h-5 opacity-75 group-hover:text-indigo-500" />
<span className="ml-2 hidden sm:block">Preferences</span>
</button>
}
>
<Preferences />
</Popover>
<Popover
placement="bottom-end"
trigger={
<button
type="button"
className="group flex items-center rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Menu className="w-5 group-hover:text-indigo-500" />
<span className="ml-2 hidden sm:block">Menu</span>
</button>
}
>
<AppMenu className="-m-2" />
</Popover>
</div>
</div>
);
};
const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
className,
}) => {
@ -31,7 +124,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<div className={clsx("space-y-1", className)}>
<Link href="/new">
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Pencil className="h-5 opacity-75" />
<Pencil className="h-5 opacity-75 " />
<span className="inline-block">New Poll</span>
</a>
</Link>
@ -48,85 +141,189 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
);
};
const UserDropdown: React.VoidFunctionComponent<
DropdownProps & { openLoginModal: () => void }
> = ({ children, openLoginModal, ...forwardProps }) => {
const { logout, user } = useSession();
const modalContext = useModalContext();
if (!user) {
return null;
}
return (
<Dropdown {...forwardProps}>
{children}
{user.isGuest ? (
<DropdownItem
icon={Question}
label="What's this?"
onClick={() => {
modalContext.render({
showClose: true,
content: (
<div className="w-96 max-w-full p-6 pt-28">
<div className="absolute left-0 -top-8 w-full text-center">
<div className="inline-flex h-20 w-20 items-center justify-center rounded-full border-8 border-white bg-gradient-to-b from-purple-400 to-indigo-500">
<User className="h-7 text-white" />
</div>
<div className="">
<div className="text-lg font-medium leading-snug">
Guest
</div>
<div className="text-sm text-slate-500">
{user.shortName}
</div>
</div>
</div>
<p>
You are using a guest session. This allows us to recognize
you if you come back later so you can edit your votes.
</p>
<div>
<a
href="https://support.rallly.co/guest-sessions"
target="_blank"
rel="noreferrer"
>
Read more about guest sessions.
</a>
</div>
</div>
),
overlayClosable: true,
footer: null,
});
}}
/>
) : null}
{user.isGuest ? (
<DropdownItem icon={Login} label="Login" onClick={openLoginModal} />
) : null}
<DropdownItem
icon={Logout}
label={user.isGuest ? "Forget me" : "Logout"}
onClick={() => {
if (user?.isGuest) {
modalContext.render({
title: "Are you sure?",
description:
"Once a guest session ends it cannot be resumed. You will not be able to edit any votes or comments you've made with this session.",
onOk: logout,
okButtonProps: {
type: "danger",
},
okText: "End session",
cancelText: "Cancel",
});
} else {
logout();
}
}}
/>
</Dropdown>
);
};
const StandardLayout: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children, ...rest }) => {
const { user } = useSession();
const [loginModal, openLoginModal] = useModal({
footer: null,
overlayClosable: true,
showClose: true,
content: <LoginForm />,
});
return (
<div
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
{...rest}
>
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between border-b px-4 lg:hidden">
<div>
<HomeLink />
</div>
<div className="flex items-center">
<Popover
placement="bottom-end"
trigger={
<button
type="button"
className="flex whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Adjustments className="h-5 opacity-75" />
</button>
}
>
<Preferences />
</Popover>
<Popover
trigger={
<button
type="button"
className="rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Menu className="w-5" />
</button>
}
>
<AppMenu className="-m-2" />
</Popover>
</div>
</div>
{loginModal}
<MobileNavigation openLoginModal={openLoginModal} />
<div className="hidden grow px-4 pt-6 pb-5 lg:block">
<div className="sticky top-6 float-right flex w-40 flex-col items-start">
<div className="mb-8 grow-0 px-2">
<div className="sticky top-6 float-right w-48 items-start">
<div className="mb-8 px-3">
<HomeLink />
</div>
<div className="mb-4 block w-full shrink-0 grow items-center pb-4 text-base">
<div className="mb-4">
<Link href="/new">
<a className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Pencil className="h-5 opacity-75" />
<span className="inline-block">New Poll</span>
<a className="group mb-1 flex items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20">
<Pencil className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
<span className="grow text-left">New Poll</span>
</a>
</Link>
<a
target="_blank"
href="https://support.rallly.co"
className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="group mb-1 flex items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20"
rel="noreferrer"
>
<Support className="h-5 opacity-75" />
<span className="inline-block">Support</span>
<Support className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
<span className="grow text-left">Support</span>
</a>
<Popover
placement="right-start"
trigger={
<button className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Adjustments className="h-5 opacity-75" />
<span className="inline-block">Preferences</span>
<button className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20">
<Adjustments className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
<span className="grow text-left">Preferences</span>
<DotsVertical className="h-4 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
}
>
<Preferences />
</Popover>
{user ? null : (
<button
onClick={openLoginModal}
className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20"
>
<Login className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
<span className="grow text-left">Login</span>
</button>
)}
</div>
<AnimatePresence initial={false}>
{user ? (
<UserDropdown
className="w-full"
placement="bottom-end"
openLoginModal={openLoginModal}
trigger={
<motion.button
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0, transition: { duration: 0.2 } }}
className="group w-full rounded-lg p-2 px-3 text-left text-inherit transition-colors hover:bg-slate-500/10 active:bg-slate-500/20"
>
<div className="flex w-full items-center space-x-3">
<div className="relative">
{user.isGuest ? (
<span className="absolute right-0 top-0 h-1 w-1 rounded-full bg-indigo-500" />
) : null}
<User className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
</div>
<div className="grow overflow-hidden">
<div className="truncate font-medium leading-snug text-slate-600">
{user.shortName}
</div>
<div className="truncate text-xs text-slate-500">
{user.isGuest ? "Guest" : "User"}
</div>
</div>
<DotsVertical className="h-4 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
</motion.button>
}
/>
) : null}
</AnimatePresence>
</div>
</div>
<div className="min-w-0 grow">
<div className="max-w-full md:w-[1024px] lg:min-h-[calc(100vh-64px)]">
<div className="max-w-full pt-12 md:w-[1024px] lg:min-h-[calc(100vh-64px)] lg:pt-0">
{children}
</div>
<div className="flex flex-col items-center space-y-4 px-6 pt-3 pb-6 text-slate-400 lg:h-16 lg:flex-row lg:space-y-0 lg:space-x-6 lg:py-0 lg:px-8 lg:pb-3">

View file

@ -19,7 +19,7 @@ const Switch: React.VoidFunctionComponent<SwitchProps> = ({
checked={checked}
onChange={onChange}
className={clsx(
"relative inline-flex h-6 w-10 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75",
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75",
{
"bg-gray-200": !checked,
"bg-green-500": checked,

View file

@ -5,6 +5,7 @@ import {
offset,
Placement,
shift,
useDismiss,
useFloating,
useHover,
useInteractions,
@ -38,6 +39,8 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
const {
reference,
floating,
refs,
update,
x,
y,
strategy,
@ -78,8 +81,17 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
useRole(context, {
role: "tooltip",
}),
useDismiss(context, { ancestorScroll: true }),
]);
React.useEffect(() => {
if (!refs.reference.current || !refs.floating.current) {
return;
}
// Only call this when the floating element is rendered
return update();
}, [update, content, refs.reference, refs.floating]);
return (
<>
<span
@ -135,4 +147,4 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
);
};
export default Tooltip;
export default React.memo(Tooltip);

View file

@ -1,6 +1,6 @@
import React from "react";
export const useRequiredContext = <T extends any>(
export const useRequiredContext = <T>(
context: React.Context<T | null>,
errorMessage?: string,
) => {

View file

@ -1,12 +0,0 @@
import React from "react";
export const UserNameContext =
React.createContext<[string, (userName: string) => void] | null>(null);
export const useUserName = () => {
const contextValue = React.useContext(UserNameContext);
if (contextValue === null) {
throw new Error("Missing UserNameContext.Provider");
}
return contextValue;
};

View file

@ -0,0 +1,63 @@
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&apos;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;

View file

@ -3,7 +3,7 @@ declare global {
interface ProcessEnv {
DATABASE_URL: string;
NODE_ENV: "development" | "production";
JWT_SECRET: string;
SECRET_PASSWORD: string;
MAINTENANCE_MODE?: "true";
PLAUSIBLE_DOMAIN?: string;
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;

View file

@ -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 {

17
declarations/iron-session.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
import "iron-session";
declare module "iron-session" {
export interface IronSessionData {
user?:
| {
id: string;
name: string;
email: string;
isGuest: false;
}
| {
id: string;
isGuest: true;
};
}
}

View file

@ -0,0 +1,17 @@
/*
Warnings:
- You are about to drop the column `verificationCode` on the `Poll` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "Poll_urlId_verificationCode_key";
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "guestId" TEXT;
-- AlterTable
ALTER TABLE "Participant" ADD COLUMN "guestId" TEXT;
-- AlterTable
ALTER TABLE "Poll" DROP COLUMN "verificationCode";

View file

@ -9,13 +9,14 @@
"analyze": "ANALYZE=true next build",
"start": "next start",
"lint": "next lint",
"lint:tsc": "tsc --noEmit",
"test": "playwright test"
},
"dependencies": {
"@floating-ui/react-dom-interactions": "^0.3.1",
"@floating-ui/react-dom-interactions": "^0.4.0",
"@headlessui/react": "^1.5.0",
"@next/bundle-analyzer": "^12.1.0",
"@prisma/client": "^3.12.0",
"@prisma/client": "^3.13.0",
"@sentry/nextjs": "^6.19.3",
"@svgr/webpack": "^6.2.1",
"@tailwindcss/forms": "^0.4.0",
@ -26,6 +27,7 @@
"date-fns-tz": "^1.2.2",
"eta": "^1.12.3",
"framer-motion": "^6.2.9",
"iron-session": "^6.1.3",
"jose": "^4.5.1",
"js-cookie": "^3.0.1",
"lodash": "^4.17.21",
@ -35,7 +37,7 @@
"next-i18next": "^10.5.0",
"next-plausible": "^3.1.9",
"nodemailer": "^6.7.2",
"prisma": "^3.12.0",
"prisma": "^3.13.0",
"react": "17.0.2",
"react-big-calendar": "^0.38.9",
"react-dom": "17.0.2",
@ -61,8 +63,8 @@
"@types/react-dom": "^17.0.13",
"@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0",
"autoprefixer": "^10.4.2",
"eslint": "^7.26.0",
"eslint-config-next": "12.1.0",

View file

@ -2,6 +2,7 @@ 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";
@ -10,30 +11,27 @@ 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 { useSessionStorage } from "react-use";
import ModalProvider from "@/components/modal/modal-provider";
import PreferencesProvider from "@/components/preferences/preferences-provider";
import { UserNameContext } from "../components/user-name-context";
const CrispChat = dynamic(() => import("@/components/crisp-chat"), {
ssr: false,
});
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: () => {
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 }) => {
const sessionUserName = useSessionStorage<string>("userName", "");
return (
<PlausibleProvider
domain="rallly.co"
@ -53,9 +51,7 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
<CrispChat />
<Toaster />
<ModalProvider>
<UserNameContext.Provider value={sessionUserName}>
<Component {...pageProps} />
</UserNameContext.Provider>
</ModalProvider>
</QueryClientProvider>
</PreferencesProvider>

View file

@ -30,6 +30,7 @@ export default function Document() {
integrity="sha512-8vEtrrc40OAQaCUaqVjNMQtQEPyNtllVG1RYy6bGEuWQkivCBeqOzuDJPPhD+MO6y6QGLuQYPCr8Nlzu9lTYaQ=="
crossOrigin="anonymous"
/>
<meta name="theme-color" content="#f9fafb" />
</Head>
<body>
<Main />

View file

@ -1,6 +1,6 @@
import { GetPollApiResponse } from "api-client/get-poll";
import { NextApiRequest, NextApiResponse } from "next";
import { exclude, getQueryParam } from "utils/api-utils";
import { getQueryParam } from "utils/api-utils";
import { LegacyPoll } from "utils/legacy-utils";
import { getMongoClient } from "utils/mongodb-client";
import { nanoid } from "utils/nanoid";
@ -53,6 +53,7 @@ export default async function handler(
const votes: Array<{ optionId: string; participantId: string }> = [];
newParticipants?.forEach((p, i) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const legacyVotes = legacyPoll.participants![i].votes;
legacyVotes?.forEach((v, j) => {
if (v) {
@ -90,7 +91,6 @@ export default async function handler(
},
},
notifications: legacyPoll.creator.allowNotifications,
verificationCode: legacyPoll.__private.verificationCode,
options: {
createMany: {
data: newOptions,
@ -157,7 +157,7 @@ export default async function handler(
});
return res.json({
...exclude(poll, "verificationCode"),
...poll,
role: "admin",
urlId: poll.urlId,
pollId: poll.urlId,

35
pages/api/login.ts Normal file
View file

@ -0,0 +1,35 @@
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;
}
});

6
pages/api/logout.ts Normal file
View file

@ -0,0 +1,6 @@
import { withSessionRoute } from "utils/auth";
export default withSessionRoute((req, res) => {
req.session.destroy();
res.send({ ok: true });
});

View file

@ -1,13 +1,10 @@
import absoluteUrl from "utils/absolute-url";
import { createGuestUser, withSessionRoute } from "utils/auth";
import { prisma } from "../../../../../db";
import {
getAdminLink,
sendEmailTemplate,
withLink,
} from "../../../../../utils/api-utils";
import { sendNotification, withLink } from "../../../../../utils/api-utils";
export default withLink(async (req, res, link) => {
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
switch (req.method) {
case "GET": {
const comments = await prisma.comment.findMany({
@ -24,56 +21,29 @@ export default withLink(async (req, res, link) => {
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,
},
});
const poll = await prisma.poll.findUnique({
where: {
urlId: link.pollId,
},
include: {
links: true,
user: true,
},
await sendNotification(req, link.pollId, {
type: "newComment",
authorName: newComment.authorName,
});
if (poll?.notifications && poll.verified && !poll.demo) {
// Get the admin link
const adminLink = getAdminLink(poll.links);
if (adminLink) {
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminLink.urlId}`;
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
try {
await sendEmailTemplate({
templateName: "new-comment",
to: poll.user.email,
subject: `Rallly: ${poll.title} - New Comment`,
templateVars: {
title: poll.title,
name: poll.authorName,
author: newComment.authorName,
pollUrl,
homePageUrl: absoluteUrl(req).origin,
supportEmail: process.env.SUPPORT_EMAIL,
unsubscribeUrl,
},
});
} catch (e) {
console.error(e);
}
} else {
console.log(`Missing admin link for poll: ${link.pollId}`);
}
}
return res.json(newComment);
}
@ -82,4 +52,5 @@ export default withLink(async (req, res, link) => {
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
});
}),
);

View file

@ -1,19 +1,13 @@
import { GetPollApiResponse } from "api-client/get-poll";
import { NextApiResponse } from "next";
import { resetDates } from "utils/legacy-utils";
import { UpdatePollPayload } from "../../../../api-client/update-poll";
import { prisma } from "../../../../db";
import { exclude, withLink } from "../../../../utils/api-utils";
import { withLink } from "../../../../utils/api-utils";
export default withLink(
async (
req,
res: NextApiResponse<
export default withLink<
GetPollApiResponse | { status: number; message: string }
>,
link,
) => {
>(async ({ req, res, link }) => {
const pollId = link.pollId;
switch (req.method) {
@ -48,9 +42,7 @@ export default withLink(
});
if (!poll) {
return res
.status(404)
.json({ status: 404, message: "Poll not found" });
return res.status(404).json({ status: 404, message: "Poll not found" });
}
if (
@ -64,7 +56,7 @@ export default withLink(
if (fixedPoll) {
return res.json({
...exclude(fixedPoll, "verificationCode"),
...fixedPoll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
@ -73,7 +65,7 @@ export default withLink(
}
return res.json({
...exclude(poll, "verificationCode"),
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
@ -143,13 +135,11 @@ export default withLink(
});
if (!poll) {
return res
.status(404)
.json({ status: 404, message: "Poll not found" });
return res.status(404).json({ status: 404, message: "Poll not found" });
}
return res.json({
...exclude(poll, "verificationCode"),
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
@ -161,5 +151,4 @@ export default withLink(
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
},
);
});

View file

@ -1,7 +1,7 @@
import { prisma } from "../../../../../db";
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
export default withLink(async (req, res, link) => {
export default withLink(async ({ req, res, link }) => {
const participantId = getQueryParam(req, "participantId");
const pollId = link.pollId;

View file

@ -1,22 +1,31 @@
import absoluteUrl from "utils/absolute-url";
import { createGuestUser, withSessionRoute } from "utils/auth";
import { AddParticipantPayload } from "../../../../../api-client/add-participant";
import { prisma } from "../../../../../db";
import {
getAdminLink,
sendEmailTemplate,
withLink,
} from "../../../../../utils/api-utils";
import { sendNotification, withLink } from "../../../../../utils/api-utils";
export default withLink(async (req, res, link) => {
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) => ({
@ -31,51 +40,15 @@ export default withLink(async (req, res, link) => {
},
});
const poll = await prisma.poll.findUnique({
where: {
urlId: link.pollId,
},
include: {
user: true,
links: true,
},
});
if (poll?.notifications && poll.verified && !poll.demo) {
// Get the admin link
const adminLink = getAdminLink(poll.links);
if (adminLink) {
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminLink.urlId}`;
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
try {
await sendEmailTemplate({
templateName: "new-participant",
to: poll.user.email,
subject: `Rallly: ${poll.title} - New Participant`,
templateVars: {
title: poll.title,
name: poll.authorName,
await sendNotification(req, link.pollId, {
type: "newParticipant",
participantName: participant.name,
pollUrl,
homePageUrl: absoluteUrl(req).origin,
supportEmail: process.env.SUPPORT_EMAIL,
unsubscribeUrl,
},
});
} catch (e) {
console.error(e);
}
} else {
console.error(`Missing admin link for poll: ${link.pollId}`);
}
}
return res.json(participant);
}
default:
return res.status(405).json({ ok: 1 });
}
});
}),
);

View file

@ -1,9 +1,16 @@
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 withLink(async (req, res, link) => {
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
if (req.method === "POST") {
if (link.role !== "admin") {
return res
@ -22,12 +29,17 @@ export default withLink(async (req, res, link) => {
});
if (!poll) {
return res.status(404).json({ status: 404, message: "Poll not found" });
return res
.status(404)
.json({ status: 404, message: "Poll not found" });
}
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${link.urlId}`;
const verifyEmailUrl = `${pollUrl}?code=${poll.verificationCode}`;
const token = await createToken({
pollId: link.pollId,
});
const verifyEmailUrl = `${pollUrl}?code=${token}`;
await sendEmailTemplate({
templateName: "new-poll",
@ -46,27 +58,52 @@ export default withLink(async (req, res, link) => {
return res.send("ok");
}
try {
await prisma.poll.update({
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_verificationCode: {
urlId: link.pollId,
verificationCode,
},
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 {
console.error(
`Failed to verify poll "${link.pollId}" with code ${verificationCode}`,
);
} 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" });
});
return res
.status(405)
.json({ status: 405, message: "Invalid http method" });
}),
);

View file

@ -36,19 +36,20 @@ export default async function handler(
const demoUser = { name: "John Example", email: "noreply@rallly.co" };
const today = new Date();
let options: Array<{ value: string; id: string }> = [];
const options: Array<{ value: string; id: string }> = [];
for (let i = 0; i < optionValues.length; i++) {
options.push({ id: await nanoid(), value: optionValues[i] });
}
let participants: Array<{
const participants: Array<{
name: string;
id: string;
guestId: string;
createdAt: Date;
}> = [];
let votes: Array<{ optionId: string; participantId: string }> = [];
const votes: Array<{ optionId: string; participantId: string }> = [];
for (let i = 0; i < participantData.length; i++) {
const { name, votes: participantVotes } = participantData[i];
@ -56,6 +57,7 @@ export default async function handler(
participants.push({
id: participantId,
name,
guestId: "user-demo",
createdAt: addMinutes(today, i * -1),
});
@ -70,7 +72,6 @@ export default async function handler(
await prisma.poll.create({
data: {
urlId: await nanoid(),
verificationCode: await nanoid(),
title: "Lunch Meeting Demo",
type: "date",
location: "Starbucks, 901 New York Avenue",

View file

@ -1,23 +1,20 @@
import { NextApiRequest, NextApiResponse } from "next";
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 async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
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(),
verificationCode: await nanoid(),
title: payload.title,
type: payload.type,
timeZone: payload.timeZone,
@ -25,6 +22,9 @@ export default async function handler(
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: {
@ -62,9 +62,27 @@ export default async function handler(
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminUrlId}`;
const verifyEmailUrl = `${pollUrl}?code=${poll.verificationCode}`;
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,
@ -78,6 +96,7 @@ export default async function handler(
supportEmail: process.env.SUPPORT_EMAIL,
},
});
}
} catch (e) {
console.error(e);
}
@ -85,6 +104,5 @@ export default async function handler(
return res.json({ urlId: adminUrlId, authorName: poll.authorName });
}
default:
return res.status(405).end();
}
}
});

20
pages/api/user.ts Normal file
View file

@ -0,0 +1,20 @@
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 });
});

112
pages/login.tsx Normal file
View file

@ -0,0 +1,112 @@
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import React from "react";
import toast from "react-hot-toast";
import { useTimeoutFn } from "react-use";
import { decryptToken, mergeGuestsIntoUser, withSessionSsr } from "utils/auth";
import { nanoid } from "utils/nanoid";
import FullPageLoader from "@/components/full-page-loader";
import { prisma } from "../db";
const Page: NextPage<{ success: boolean; redirectTo: string }> = ({
success,
redirectTo,
}) => {
const router = useRouter();
if (!success) {
toast.error("Login failed! Link is expired or invalid");
}
useTimeoutFn(() => {
router.replace(redirectTo);
}, 100);
return (
<>
<Head>
<title>Logging in</title>
</Head>
<FullPageLoader>Logging in</FullPageLoader>
</>
);
};
export const getServerSideProps: GetServerSideProps = withSessionSsr(
async ({ req, query }) => {
const { code } = query;
if (typeof code !== "string") {
return {
props: {},
redirect: {
destination: "/new",
},
};
}
const {
email,
path = "/new",
guestId,
} = await decryptToken<{
email?: string;
path?: string;
guestId?: string;
}>(code);
if (!email) {
return {
props: {
success: false,
redirectTo: path,
},
};
}
const user = await prisma.user.upsert({
where: { email },
update: {},
create: {
id: await nanoid(),
name: email.substring(0, email.indexOf("@")),
email,
},
});
const guestIds: string[] = [];
// guest id from existing sessions
if (req.session.user?.isGuest) {
guestIds.push(req.session.user.id);
}
// guest id from token
if (guestId && guestId !== req.session.user?.id) {
guestIds.push(guestId);
}
if (guestIds.length > 0) {
await mergeGuestsIntoUser(user.id, guestIds);
}
req.session.user = {
isGuest: false,
name: user.name,
email: user.email,
id: user.id,
};
await req.session.save();
return {
props: {
success: true,
redirectTo: path,
},
};
},
);
export default Page;

View file

@ -1,19 +1,26 @@
import { GetServerSideProps } from "next";
import dynamic from "next/dynamic";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { withSessionSsr } from "utils/auth";
export const getServerSideProps: GetServerSideProps = async ({
import { CreatePollPageProps } from "@/components/create-poll";
const getProps: GetServerSideProps<CreatePollPageProps> = async ({
locale = "en",
query,
req,
}) => {
return {
props: {
...(await serverSideTranslations(locale, ["app"])),
...query,
user: req.session.user ?? null,
},
};
};
export const getServerSideProps = withSessionSsr(getProps);
// We disable SSR because the data on this page relies on sessionStore
export default dynamic(() => import("@/components/create-poll"), {
ssr: false,

View file

@ -7,16 +7,18 @@ 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 { 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 = () => {
const PollPageLoader: NextPage<SessionProps> = () => {
const { query } = useRouter();
const { t } = useTranslation("app");
const urlId = query.urlId as string;
@ -71,29 +73,30 @@ const PollPageLoader: NextPage = () => {
);
}
if (poll) {
return <PollPage poll={poll} />;
}
if (didError) {
return <Custom404 />;
}
return !poll ? (
<FullPageLoader>{t("loading")}</FullPageLoader>
) : (
<PollPage poll={poll} />
);
return <FullPageLoader>{t("loading")}</FullPageLoader>;
};
export const getServerSideProps: GetServerSideProps = async ({
locale = "en",
}) => {
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 PollPageLoader;
export default withSession(PollPageLoader);

View file

@ -1,10 +1,7 @@
# postgres database - not needed if running with docker-compose
DATABASE_URL=postgres://your-database/db
# support email - used as FROM email by SMTP server
SUPPORT_EMAIL=foo@yourdomain.com
# SMTP server - required if you want to send emails
SMTP_HOST=your-smtp-server
SMTP_PORT=587
SMTP_SECURE="false"
SMTP_SECURE=false
SMTP_USER=your-smtp-user
SMTP_PWD=your-smtp-password

View file

@ -34,7 +34,6 @@ model Poll {
user User @relation(fields: [userId], references: [id])
userId String
votes Vote[]
verificationCode String
timeZone String?
verified Boolean @default(false)
options Option[]
@ -46,8 +45,6 @@ model Poll {
legacy Boolean @default(false)
closed Boolean @default(false)
notifications Boolean @default(false)
@@unique([urlId, verificationCode])
}
enum Role {
@ -70,11 +67,13 @@ model Participant {
name String
user User? @relation(fields: [userId], references: [id])
userId String?
guestId String?
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String
votes Vote[]
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@unique([id, pollId])
}
@ -103,11 +102,12 @@ model Vote {
model Comment {
id String @id @default(cuid())
content String
poll Poll @relation(fields:[pollId], references: [urlId], onDelete: Cascade)
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String
authorName String
user User? @relation(fields: [userId], references: [id])
userId String?
guestId String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt

View file

@ -7,6 +7,7 @@
body,
#__next {
height: 100%;
@apply bg-slate-50;
}
body {
@apply bg-slate-50 text-base text-slate-600;
@ -37,7 +38,7 @@
@apply mb-1 block text-sm text-slate-800;
}
button {
@apply focus:outline-none focus:ring-indigo-600;
@apply cursor-default focus:outline-none focus:ring-indigo-600;
}
#floating-ui-root {
@ -59,23 +60,23 @@
@apply input px-3 py-3;
}
.input-error {
@apply border-rose-500 bg-rose-50 placeholder:text-rose-500 focus:border-rose-400 focus:ring-rose-500;
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
}
.checkbox {
@apply h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
}
.btn {
@apply inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-1;
@apply inline-flex h-9 cursor-default items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
}
a.btn {
@apply hover:no-underline;
}
.btn-default {
@apply btn border-gray-300 bg-white text-gray-700 hover:text-indigo-500 focus:border-transparent focus:ring-indigo-500 focus:ring-offset-0 active:bg-gray-100;
@apply btn border-gray-300 bg-white text-gray-700 hover:text-indigo-500 focus-visible:border-transparent focus-visible:ring-indigo-500 focus-visible:ring-offset-0 active:bg-gray-100;
}
.btn-danger {
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus:ring-rose-500;
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
}
.btn-link {
@apply inline-flex items-center text-indigo-500 underline;
@ -87,7 +88,12 @@
@apply pointer-events-none;
}
.btn-primary {
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus:ring-indigo-500 active:bg-indigo-600;
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus-visible:ring-indigo-500 active:bg-indigo-600;
}
.btn-primary.btn-disabled {
text-shadow: none;
@apply border-gray-300/70 bg-gray-200/70 text-gray-400;
}
a.btn-primary {
@apply text-white;

148
templates/login.html Normal file
View file

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<title>Login with your email</title>
<style>
.hover-bg-indigo-400:hover {
background-color: #818cf8 !important;
}
.hover-underline:hover {
text-decoration: underline !important;
}
.hover-no-underline:hover {
text-decoration: none !important;
}
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
.sm-py-32 {
padding-top: 32px !important;
padding-bottom: 32px !important;
}
.sm-px-24 {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; word-break: break-word; -webkit-font-smoothing: antialiased; background-color: #f3f4f6;">
<div style="display: none;">
Please click the link below to verify your email address.&#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847;
</div>
<div role="article" aria-roledescription="email" aria-label="Login with your email" lang="en">
<table style="width: 100%; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="background-color: #f3f4f6;">
<table class="sm-w-full" style="width: 600px;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>">
<img src="https://rallly.co/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
<tr>
<td align="center" class="sm-px-24">
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-px-24" style="border-radius: 4px; background-color: #ffffff; padding: 36px; text-align: left; font-size: 16px; line-height: 24px; color: #1f2937;">
<p style="margin-bottom: 8px;">Hey there,</p>
<p style="margin-bottom: 8px;">
To login with your email please click the button below:
</p>
<p style="margin-bottom: 24px;"></p>
<div style="line-height: 100%;">
<a href="<%= it.loginUrl %>" class="hover-bg-indigo-400" style="display: inline-block; border-radius: 4px; background-color: #6366f1; padding-top: 16px; padding-bottom: 16px; padding-left: 24px; padding-right: 24px; text-align: center; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Log me in &rarr;
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>
</div>
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding-top: 32px; padding-bottom: 32px;">
<div style="height: 1px; background-color: #e5e7eb; line-height: 1px;">
&zwnj;
</div>
</td>
</tr>
</table>
<p>
Not sure why you received this email? Please
<a href="mailto:<%= it.supportEmail %>" class="hover-no-underline" style="color: #6366f1; text-decoration: underline;">let us know</a>.
</p>
</td>
</tr>
<tr>
<td style="height: 48px;"></td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #4b5563;">
<p style="margin-bottom: 4px; text-transform: uppercase;">RALLLY</p>
<p style="font-style: italic;">Collaborative Scheduling</p>
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View file

@ -83,7 +83,7 @@
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>>">
<img src="http://cdn.mcauto-images-production.sendgrid.net/6ee16014c94c9785/404bb93c-4da3-4f34-b94b-0b6d4e9e6f74/300x56.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
<img src="https://rallly.co/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
@ -132,6 +132,10 @@
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>

View file

@ -83,7 +83,7 @@
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>">
<img src="http://cdn.mcauto-images-production.sendgrid.net/6ee16014c94c9785/404bb93c-4da3-4f34-b94b-0b6d4e9e6f74/300x56.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
<img src="https://rallly.co/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
@ -132,6 +132,10 @@
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>

View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<title>Your poll has been created</title>
<style>
.hover-bg-indigo-400:hover {
background-color: #818cf8 !important;
}
.hover-underline:hover {
text-decoration: underline !important;
}
.hover-no-underline:hover {
text-decoration: none !important;
}
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
.sm-py-32 {
padding-top: 32px !important;
padding-bottom: 32px !important;
}
.sm-px-24 {
padding-left: 24px !important;
padding-right: 24px !important;
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; word-break: break-word; -webkit-font-smoothing: antialiased; background-color: #f3f4f6;">
<div style="display: none;">
Please click the link below to verify your email address!&#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847;
</div>
<div role="article" aria-roledescription="email" aria-label="Your poll has been created" lang="en">
<table style="width: 100%; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="background-color: #f3f4f6;">
<table class="sm-w-full" style="width: 600px;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>">
<img src="https://rallly.co/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
<tr>
<td align="center" class="sm-px-24">
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="sm-px-24" style="border-radius: 4px; background-color: #ffffff; padding: 36px; text-align: left; font-size: 16px; line-height: 24px; color: #1f2937;">
<p style="margin-bottom: 8px;">Hi <%= it.name %>,</p>
<p style="margin-bottom: 8px;">
Your poll <strong>"<%= it.title %>"</strong> has been
created.
</p>
<p style="margin-bottom: 24px;"></p>
<div style="line-height: 100%;">
<a href="<%= it.pollUrl %>" class="hover-bg-indigo-400" style="display: inline-block; border-radius: 4px; background-color: #6366f1; padding-top: 16px; padding-bottom: 16px; padding-left: 24px; padding-right: 24px; text-align: center; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none;"> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%; mso-text-raise: 26pt;">&nbsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Got to poll &rarr;
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>
</div>
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding-top: 32px; padding-bottom: 32px;">
<div style="height: 1px; background-color: #e5e7eb; line-height: 1px;">
&zwnj;
</div>
</td>
</tr>
</table>
<p>
Not sure why you received this email? Please
<a href="mailto:<%= it.supportEmail %>" class="hover-no-underline" style="color: #6366f1; text-decoration: underline;">let us know</a>.
</p>
</td>
</tr>
<tr>
<td style="height: 48px;"></td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #4b5563;">
<p style="margin-bottom: 4px; text-transform: uppercase;">RALLLY</p>
<p style="font-style: italic;">Collaborative Scheduling</p>
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View file

@ -83,7 +83,7 @@
<tr>
<td class="sm-py-32 sm-px-24" style="padding-left: 48px; padding-right: 48px; padding-top: 36px; padding-bottom: 36px; text-align: center;">
<a href="<%= it.homePageUrl %>">
<img src="http://cdn.mcauto-images-production.sendgrid.net/6ee16014c94c9785/404bb93c-4da3-4f34-b94b-0b6d4e9e6f74/300x56.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
<img src="https://rallly.co/logo.png" width="150" alt="Rallly" style="max-width: 100%; vertical-align: middle; line-height: 100%; border: 0;">
</a>
</td>
</tr>
@ -105,11 +105,6 @@
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a>
</div>
<p style="margin-bottom: 24px; font-size: 14px; color: #6b7280;">
If the link doesn't work, copy this address in to your
browser:<br>
<span style="color: #9ca3af;"><%= it.verifyEmailUrl %></span>
</p>
<p style="margin-bottom: 8px;">
In case you lose it, here's a link to your poll for the
future 😉
@ -144,6 +139,10 @@
<p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull;
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
&bull;
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
&bull;
<a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p>
</td>

View file

@ -12,7 +12,7 @@ test("should show warning when deleting options with votes in them", async ({
await page.click("[data-testid='specify-times-switch']");
await page.click("text='12:00 PM'");
await page.click("text='1:00 PM'");
await page.click("text='Save'");
await page.locator("div[role='dialog']").locator("text='Save'").click();
await expect(page.locator('text="Are you sure?"')).toBeVisible();
await page.click("text='Delete'");
});

View file

@ -14,6 +14,10 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await page.click("text=Save");
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(page.locator("data-testid=user")).toBeVisible();
await expect(
page.locator("data-testid=participant-selector").locator("text=You"),
).toBeVisible();
await page.click("text=Edit");
await page.click("data-testid=poll-option >> nth=1");

View file

@ -5,7 +5,6 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible();
await page.click("text='New Participant'");
await page.type('[placeholder="Your name"]', "Test user");
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
// when we only have a single option/checkbox.
@ -13,9 +12,18 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await page.locator('[name="votes"] >> nth=3').click();
await page.click('[data-testid="submitNewParticipant"]');
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(page.locator("text=Guest")).toBeVisible();
await expect(
page.locator("data-testid=participant-row >> nth=0").locator("text=You"),
).toBeVisible();
await page.type(
"[placeholder='Thanks for the invite!']",
"This is a comment!",
);
await page.type('[placeholder="Your name…"]', "Test user");
await page.click("text='Comment'");
await page.type("[placeholder='Add your comment…']", "This is a comment!");
await page.click("text='Send'");
await expect(page.locator("text='This is a comment!'")).toBeVisible();
const comment = page.locator("data-testid=comment");
await expect(comment.locator("text='This is a comment!'")).toBeVisible();
await expect(comment.locator("text=You")).toBeVisible();
});

View file

@ -26,6 +26,6 @@
"@/components/*": ["components/*"]
}
},
"exclude": ["**/node_modules", "**/.*/", "**/*.js"],
"exclude": ["node_modules", "**/.*/", "**/*.js"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View file

@ -5,47 +5,128 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import path from "path";
import { prisma } from "../db";
import absoluteUrl from "./absolute-url";
import { sendEmail } from "./send-email";
export const exclude = <O extends Record<string, any>, E extends keyof O>(
obj: O,
...fieldsToExclude: E[]
): Omit<O, E> => {
const newObj = { ...obj };
fieldsToExclude.forEach((field) => {
delete newObj[field];
});
return newObj;
};
export const getQueryParam = (req: NextApiRequest, queryKey: string) => {
const value = req.query[queryKey];
return typeof value === "string" ? value : value[0];
};
export const withLink = (
handler: (
req: NextApiRequest,
res: NextApiResponse,
link: Link,
) => Promise<void>,
type ApiMiddleware<T, P extends Record<string, unknown>> = (
ctx: {
req: NextApiRequest;
res: NextApiResponse<T>;
} & P,
) => Promise<void | NextApiResponse>;
/**
* Gets the Link from `req.query.urlId` and passes it to handler
* @param handler
* @returns
*/
export const withLink = <T>(
handler: ApiMiddleware<T, { link: Link }>,
): NextApiHandler => {
return async (req, res) => {
const urlId = getQueryParam(req, "urlId");
const link = await prisma.link.findUnique({ where: { urlId } });
if (!link) {
const message = `Could not find link with urlId: ${urlId}`;
return res.status(404).json({
res.status(404).json({
status: 404,
message,
message: `Could not find link with urlId: ${urlId}`,
});
return;
}
return await handler(req, res, link);
await handler({ req, res, link });
return;
};
};
type NotificationAction =
| {
type: "newParticipant";
participantName: string;
}
| {
type: "newComment";
authorName: string;
};
export const sendNotification = async (
req: NextApiRequest,
pollId: string,
action: NotificationAction,
): Promise<void> => {
try {
const poll = await prisma.poll.findUnique({
where: { urlId: pollId },
include: { user: true, links: true },
});
/**
* poll needs to:
* - exist
* - be verified
* - not be a demo
* - have notifications turned on
*/
if (
poll &&
poll?.user.email &&
poll.verified &&
!poll.demo &&
poll.notifications
) {
const adminLink = getAdminLink(poll.links);
if (!adminLink) {
throw new Error(`Missing admin link for poll: ${pollId}`);
}
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminLink.urlId}`;
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
switch (action.type) {
case "newParticipant":
await sendEmailTemplate({
templateName: "new-participant",
to: poll.user.email,
subject: `Rallly: ${poll.title} - New Participant`,
templateVars: {
title: poll.title,
name: poll.authorName,
participantName: action.participantName,
pollUrl,
homePageUrl: absoluteUrl(req).origin,
supportEmail: process.env.SUPPORT_EMAIL,
unsubscribeUrl,
},
});
break;
case "newComment":
await sendEmailTemplate({
templateName: "new-comment",
to: poll.user.email,
subject: `Rallly: ${poll.title} - New Comment`,
templateVars: {
title: poll.title,
name: poll.authorName,
author: action.authorName,
pollUrl,
homePageUrl: absoluteUrl(req).origin,
supportEmail: process.env.SUPPORT_EMAIL,
unsubscribeUrl,
},
});
break;
}
}
} catch (e) {
console.error(e);
}
};
export const getAdminLink = (links: Link[]) =>
links.find((link) => link.role === "admin");

View file

@ -1,5 +1,78 @@
import { jwtVerify } from "jose";
import { IronSessionOptions, sealData, unsealData } from "iron-session";
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import { GetServerSideProps, NextApiHandler, NextApiRequest } from "next";
export const verifyJwt = async (jwt: string) => {
return await jwtVerify(jwt, new TextEncoder().encode(process.env.JWT_SECRET));
import { prisma } from "../db";
import { randomid } from "./nanoid";
const sessionOptions: IronSessionOptions = {
password: process.env.SECRET_PASSWORD,
cookieName: "rallly-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
ttl: 0, // basically forever
};
export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, sessionOptions);
}
export function withSessionSsr(handler: GetServerSideProps) {
return withIronSessionSsr(handler, sessionOptions);
}
export const decryptToken = async <P extends Record<string, unknown>>(
token: string,
): Promise<P> => {
return await unsealData(token, { password: sessionOptions.password });
};
export const createToken = async <T extends Record<string, unknown>>(
payload: T,
) => {
return await sealData(payload, {
password: sessionOptions.password,
ttl: 60 * 15, // 15 minutes
});
};
export const createGuestUser = async (req: NextApiRequest) => {
req.session.user = {
id: `user-${await randomid()}`,
isGuest: true,
};
await req.session.save();
};
// assigns participants and comments created by guests to a user
// we could have multiple guests because a login might be triggered from one device
// and opened in another one.
export const mergeGuestsIntoUser = async (
userId: string,
guestIds: string[],
) => {
await prisma.participant.updateMany({
where: {
guestId: {
in: guestIds,
},
},
data: {
guestId: null,
userId: userId,
},
});
await prisma.comment.updateMany({
where: {
guestId: {
in: guestIds,
},
},
data: {
guestId: null,
userId: userId,
},
});
};

View file

@ -1 +1,4 @@
export const requiredString = (value: string) => !!value.trim();
export const validEmail = (value: string) =>
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);

View file

@ -1,62 +0,0 @@
import { Option, Participant, Vote } from "@prisma/client";
export const generateFakeParticipants = (names: string[], dates: string[]) => {
const pollId = "mock";
const options: Option[] = dates.map((date) => {
return {
id: date,
value: date,
pollId,
};
});
const participants: Array<Participant & { votes: Vote[] }> = names.map(
(name, i) => {
return {
name,
id: `participant${i}`,
pollId,
userId: null,
createdAt: new Date(),
votes: [],
};
},
);
const mockVotes: number[][] = [
[1, 1, 1, 0],
[1, 0, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 0],
];
const optionsWithVotes: Array<Option & { votes: Vote[] }> = options.map(
(option, optionIndex) => {
const votes: Vote[] = [];
participants.map((participant, participantIndex) => {
if (mockVotes[participantIndex][optionIndex]) {
const vote: Vote = {
id: participant.id + option.id,
participantId: participant.id,
optionId: option.id,
pollId,
};
votes.push(vote);
participant.votes.push(vote);
}
});
return { ...option, votes };
},
);
let highScore = 0;
optionsWithVotes.forEach((option) => {
if (option.votes.length > highScore) {
highScore = option.votes.length;
}
});
return { participants, options: optionsWithVotes, highScore };
};

View file

@ -4,3 +4,8 @@ export const nanoid = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
12,
);
export const randomid = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyz",
12,
);

377
yarn.lock
View file

@ -1150,14 +1150,13 @@
dependencies:
"@floating-ui/core" "^0.6.2"
"@floating-ui/react-dom-interactions@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.3.1.tgz#abc0cb4b18e6f095397e50f9846572eee4e34554"
integrity sha512-tP2KEh7EHJr5hokSBHcPGojb+AorDNUf0NYfZGg/M+FsMvCOOsSEeEF0O1NDfETIzDnpbHnCs0DuvCFhSMSStg==
"@floating-ui/react-dom-interactions@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.4.0.tgz#b4d951aaa3b0a66cd0b2787a7bf9d5d7b2f12021"
integrity sha512-pcXxg2QVrQmlo54v39fIfPNda3bkFibuQVji0b4I9PLXOTV+KI5phc8ANnKLdfttfsYap/0bAknS9dQW97KShw==
dependencies:
"@floating-ui/react-dom" "^0.6.3"
aria-hidden "^1.1.3"
point-in-polygon "^1.1.0"
use-isomorphic-layout-effect "^1.1.1"
"@floating-ui/react-dom@^0.6.3":
@ -1168,11 +1167,48 @@
"@floating-ui/dom" "^0.4.5"
use-isomorphic-layout-effect "^1.1.1"
"@hapi/hoek@^9.0.0":
"@hapi/b64@5.x.x":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@hapi/b64/-/b64-5.0.0.tgz#b8210cbd72f4774985e78569b77e97498d24277d"
integrity sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==
dependencies:
"@hapi/hoek" "9.x.x"
"@hapi/boom@9.x.x":
version "9.1.4"
resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.4.tgz#1f9dad367c6a7da9f8def24b4a986fc5a7bd9db6"
integrity sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==
dependencies:
"@hapi/hoek" "9.x.x"
"@hapi/bourne@2.x.x":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-2.1.0.tgz#66aff77094dc3080bd5df44ec63881f2676eb020"
integrity sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==
"@hapi/cryptiles@5.x.x":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/cryptiles/-/cryptiles-5.1.0.tgz#655de4cbbc052c947f696148c83b187fc2be8f43"
integrity sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==
dependencies:
"@hapi/boom" "9.x.x"
"@hapi/hoek@9.x.x", "@hapi/hoek@^9.0.0":
version "9.2.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
integrity sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==
"@hapi/iron@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@hapi/iron/-/iron-6.0.0.tgz#ca3f9136cda655bdd6028de0045da0de3d14436f"
integrity sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==
dependencies:
"@hapi/b64" "5.x.x"
"@hapi/boom" "9.x.x"
"@hapi/bourne" "2.x.x"
"@hapi/cryptiles" "5.x.x"
"@hapi/hoek" "9.x.x"
"@hapi/topo@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012"
@ -1359,22 +1395,22 @@
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
"@prisma/client@^3.12.0":
version "3.12.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12"
integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw==
"@prisma/client@^3.13.0":
version "3.13.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.13.0.tgz#84511ebdf6ba75f77ca08495b9f73f22c4255654"
integrity sha512-lnEA2tTyVbO5mS1ehmHJQKBDiKB8shaR6s3azwj3Azfi5XHIfnqmkolLCvUeFYnkDCNVzGXJpUgKwQt/UOOYVQ==
dependencies:
"@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines-version" "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886"
integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw==
"@prisma/engines-version@3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b":
version "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#676aca309d66d9be2aad8911ca31f1ee5561041c"
integrity sha512-TGp9rvgJIKo8NgvAHSwOosbut9mTA7VC6/rpQI9gh+ySSRjdQFhbGyNUiOcQrlI9Ob2DWeO7y4HEnhdKxYiECg==
"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45"
integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ==
"@prisma/engines@3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b":
version "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#d3a457cec4ef7a3b3412c45b1f2eac68c974474b"
integrity sha512-Ip9CcCeUocH61eXu4BUGpvl5KleQyhcUVLpWCv+0ZmDv44bFaDpREqjGHHdRupvPN/ugB6gTlD9b9ewdj02yVA==
"@restart/hooks@^0.3.25":
version "0.3.26"
@ -1665,6 +1701,45 @@
resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@types/body-parser@*":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/connect@*":
version "3.4.35"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
dependencies:
"@types/node" "*"
"@types/cookie@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554"
integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==
"@types/express-serve-static-core@^4.17.18":
version "4.17.28"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8"
integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==
dependencies:
"@types/node" "*"
"@types/qs" "*"
"@types/range-parser" "*"
"@types/express@^4.17.13":
version "4.17.13"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^4.17.18"
"@types/qs" "*"
"@types/serve-static" "*"
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz"
@ -1697,10 +1772,10 @@
resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz"
integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
"@types/json-schema@^7.0.3":
version "7.0.7"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz"
integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/json5@^0.0.29":
version "0.0.29"
@ -1712,6 +1787,11 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz"
integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==
"@types/mime@^1":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
"@types/mixpanel-browser@^2.38.0":
version "2.38.0"
resolved "https://registry.yarnpkg.com/@types/mixpanel-browser/-/mixpanel-browser-2.38.0.tgz#b3e28e1ba06c10a9f88510b88f1ac9d1b2adfc42"
@ -1722,6 +1802,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644"
integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==
"@types/node@^16.11.7":
version "16.11.32"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.32.tgz#ff1a57f7c52dacb3537d22d230654390202774de"
integrity sha512-+fnfNvG5JQdC1uGZiTx+0QVtoOHcggy6+epx65JYroPGsE1uhp+vo5kioiGKsAkor6ocwHteU2EvO7N8vtOZtA==
"@types/nodemailer@^6.4.4":
version "6.4.4"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b"
@ -1739,6 +1824,16 @@
resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/qs@*":
version "6.9.7"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
"@types/range-parser@*":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-big-calendar@^0.31.0":
version "0.31.0"
resolved "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-0.31.0.tgz"
@ -1775,6 +1870,14 @@
resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz"
integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==
"@types/serve-static@*":
version "1.13.10"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/smoothscroll-polyfill@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.1.tgz#77fb3a6e116bdab4a5959122e3b8e201224dcd49"
@ -1822,41 +1925,20 @@
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@^4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.23.0.tgz"
integrity sha512-tGK1y3KIvdsQEEgq6xNn1DjiFJtl+wn8JJQiETtCbdQxw1vzjXyAaIkEmO2l6Nq24iy3uZBMFQjZ6ECf1QdgGw==
"@typescript-eslint/eslint-plugin@^5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.21.0.tgz#bfc22e0191e6404ab1192973b3b4ea0461c1e878"
integrity sha512-fTU85q8v5ZLpoZEyn/u1S2qrFOhi33Edo2CZ0+q1gDaWWm0JuPh3bgOyU8lM0edIEYgKLDkPFiZX2MOupgjlyg==
dependencies:
"@typescript-eslint/experimental-utils" "4.23.0"
"@typescript-eslint/scope-manager" "4.23.0"
debug "^4.1.1"
"@typescript-eslint/scope-manager" "5.21.0"
"@typescript-eslint/type-utils" "5.21.0"
"@typescript-eslint/utils" "5.21.0"
debug "^4.3.2"
functional-red-black-tree "^1.0.1"
lodash "^4.17.15"
regexpp "^3.0.0"
semver "^7.3.2"
tsutils "^3.17.1"
"@typescript-eslint/experimental-utils@4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.23.0.tgz"
integrity sha512-WAFNiTDnQfrF3Z2fQ05nmCgPsO5o790vOhmWKXbbYQTO9erE1/YsFot5/LnOUizLzU2eeuz6+U/81KV5/hFTGA==
dependencies:
"@types/json-schema" "^7.0.3"
"@typescript-eslint/scope-manager" "4.23.0"
"@typescript-eslint/types" "4.23.0"
"@typescript-eslint/typescript-estree" "4.23.0"
eslint-scope "^5.0.0"
eslint-utils "^2.0.0"
"@typescript-eslint/parser@^4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.23.0.tgz"
integrity sha512-wsvjksHBMOqySy/Pi2Q6UuIuHYbgAMwLczRl4YanEPKW5KVxI9ZzDYh3B5DtcZPQTGRWFJrfcbJ6L01Leybwug==
dependencies:
"@typescript-eslint/scope-manager" "4.23.0"
"@typescript-eslint/types" "4.23.0"
"@typescript-eslint/typescript-estree" "4.23.0"
debug "^4.1.1"
ignore "^5.1.8"
regexpp "^3.2.0"
semver "^7.3.5"
tsutils "^3.21.0"
"@typescript-eslint/parser@^5.0.0":
version "5.16.0"
@ -1868,13 +1950,15 @@
"@typescript-eslint/typescript-estree" "5.16.0"
debug "^4.3.2"
"@typescript-eslint/scope-manager@4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.23.0.tgz"
integrity sha512-ZZ21PCFxPhI3n0wuqEJK9omkw51wi2bmeKJvlRZPH5YFkcawKOuRMQMnI8mH6Vo0/DoHSeZJnHiIx84LmVQY+w==
"@typescript-eslint/parser@^5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.21.0.tgz#6cb72673dbf3e1905b9c432175a3c86cdaf2071f"
integrity sha512-8RUwTO77hstXUr3pZoWZbRQUxXcSXafZ8/5gpnQCfXvgmP9gpNlRGlWzvfbEQ14TLjmtU8eGnONkff8U2ui2Eg==
dependencies:
"@typescript-eslint/types" "4.23.0"
"@typescript-eslint/visitor-keys" "4.23.0"
"@typescript-eslint/scope-manager" "5.21.0"
"@typescript-eslint/types" "5.21.0"
"@typescript-eslint/typescript-estree" "5.21.0"
debug "^4.3.2"
"@typescript-eslint/scope-manager@5.16.0":
version "5.16.0"
@ -1884,28 +1968,32 @@
"@typescript-eslint/types" "5.16.0"
"@typescript-eslint/visitor-keys" "5.16.0"
"@typescript-eslint/types@4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.23.0.tgz"
integrity sha512-oqkNWyG2SLS7uTWLZf6Sr7Dm02gA5yxiz1RP87tvsmDsguVATdpVguHr4HoGOcFOpCvx9vtCSCyQUGfzq28YCw==
"@typescript-eslint/scope-manager@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.21.0.tgz#a4b7ed1618f09f95e3d17d1c0ff7a341dac7862e"
integrity sha512-XTX0g0IhvzcH/e3393SvjRCfYQxgxtYzL3UREteUneo72EFlt7UNoiYnikUtmGVobTbhUDByhJ4xRBNe+34kOQ==
dependencies:
"@typescript-eslint/types" "5.21.0"
"@typescript-eslint/visitor-keys" "5.21.0"
"@typescript-eslint/type-utils@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.21.0.tgz#ff89668786ad596d904c21b215e5285da1b6262e"
integrity sha512-MxmLZj0tkGlkcZCSE17ORaHl8Th3JQwBzyXL/uvC6sNmu128LsgjTX0NIzy+wdH2J7Pd02GN8FaoudJntFvSOw==
dependencies:
"@typescript-eslint/utils" "5.21.0"
debug "^4.3.2"
tsutils "^3.21.0"
"@typescript-eslint/types@5.16.0":
version "5.16.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee"
integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g==
"@typescript-eslint/typescript-estree@4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.23.0.tgz"
integrity sha512-5Sty6zPEVZF5fbvrZczfmLCOcby3sfrSPu30qKoY1U3mca5/jvU5cwsPb/CO6Q3ByRjixTMIVsDkqwIxCf/dMw==
dependencies:
"@typescript-eslint/types" "4.23.0"
"@typescript-eslint/visitor-keys" "4.23.0"
debug "^4.1.1"
globby "^11.0.1"
is-glob "^4.0.1"
semver "^7.3.2"
tsutils "^3.17.1"
"@typescript-eslint/types@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.21.0.tgz#8cdb9253c0dfce3f2ab655b9d36c03f72e684017"
integrity sha512-XnOOo5Wc2cBlq8Lh5WNvAgHzpjnEzxn4CJBwGkcau7b/tZ556qrWXQz4DJyChYg8JZAD06kczrdgFPpEQZfDsA==
"@typescript-eslint/typescript-estree@5.16.0":
version "5.16.0"
@ -1920,13 +2008,30 @@
semver "^7.3.5"
tsutils "^3.21.0"
"@typescript-eslint/visitor-keys@4.23.0":
version "4.23.0"
resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.23.0.tgz"
integrity sha512-5PNe5cmX9pSifit0H+nPoQBXdbNzi5tOEec+3riK+ku4e3er37pKxMKDH5Ct5Y4fhWxcD4spnlYjxi9vXbSpwg==
"@typescript-eslint/typescript-estree@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.21.0.tgz#9f0c233e28be2540eaed3df050f0d54fb5aa52de"
integrity sha512-Y8Y2T2FNvm08qlcoSMoNchh9y2Uj3QmjtwNMdRQkcFG7Muz//wfJBGBxh8R7HAGQFpgYpdHqUpEoPQk+q9Kjfg==
dependencies:
"@typescript-eslint/types" "4.23.0"
eslint-visitor-keys "^2.0.0"
"@typescript-eslint/types" "5.21.0"
"@typescript-eslint/visitor-keys" "5.21.0"
debug "^4.3.2"
globby "^11.0.4"
is-glob "^4.0.3"
semver "^7.3.5"
tsutils "^3.21.0"
"@typescript-eslint/utils@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.21.0.tgz#51d7886a6f0575e23706e5548c7e87bce42d7c18"
integrity sha512-q/emogbND9wry7zxy7VYri+7ydawo2HDZhRZ5k6yggIvXa7PvBbAAZ4PFH/oZLem72ezC4Pr63rJvDK/sTlL8Q==
dependencies:
"@types/json-schema" "^7.0.9"
"@typescript-eslint/scope-manager" "5.21.0"
"@typescript-eslint/types" "5.21.0"
"@typescript-eslint/typescript-estree" "5.21.0"
eslint-scope "^5.1.1"
eslint-utils "^3.0.0"
"@typescript-eslint/visitor-keys@5.16.0":
version "5.16.0"
@ -1936,6 +2041,14 @@
"@typescript-eslint/types" "5.16.0"
eslint-visitor-keys "^3.0.0"
"@typescript-eslint/visitor-keys@5.21.0":
version "5.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.21.0.tgz#453fb3662409abaf2f8b1f65d515699c888dd8ae"
integrity sha512-SX8jNN+iHqAF0riZQMkm7e8+POXa/fXw5cxL+gjpyP+FI+JVNhii53EmQgDAfDcBpFekYSlO0fGytMQwRiMQCA==
dependencies:
"@typescript-eslint/types" "5.21.0"
eslint-visitor-keys "^3.0.0"
"@xobotyi/scrollbar-width@^1.9.5":
version "1.9.5"
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
@ -2469,6 +2582,11 @@ cookie@^0.4.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
cookie@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
copy-to-clipboard@^3.3.1:
version "3.3.1"
resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz"
@ -3058,7 +3176,7 @@ eslint-plugin-simple-import-sort@^7.0.0:
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-7.0.0.tgz#a1dad262f46d2184a90095a60c66fef74727f0f8"
integrity sha512-U3vEDB5zhYPNfxT5TYR7u01dboFZp+HNpnGhkDB2g/2E4wZ/g1Q9Ton8UwCLfRV9yAKyYqDh62oHOamvkFxsvw==
eslint-scope@^5.0.0, eslint-scope@^5.1.1:
eslint-scope@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
@ -3066,13 +3184,20 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.1:
esrecurse "^4.3.0"
estraverse "^4.1.1"
eslint-utils@^2.0.0, eslint-utils@^2.1.0:
eslint-utils@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz"
integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
dependencies:
eslint-visitor-keys "^1.1.0"
eslint-utils@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
dependencies:
eslint-visitor-keys "^2.0.0"
eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz"
@ -3212,18 +3337,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.1.1:
version "3.2.5"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz"
integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.0"
merge2 "^1.3.0"
micromatch "^4.0.2"
picomatch "^2.2.1"
fast-glob@^3.2.11, fast-glob@^3.2.9:
version "3.2.11"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz"
@ -3406,7 +3519,7 @@ github-buttons@^2.21.1:
resolved "https://registry.yarnpkg.com/github-buttons/-/github-buttons-2.21.1.tgz#9e55eb83b70c9149a21c235db2e971c53d4d98a2"
integrity sha512-n9bCQ8sj+5oX1YH5NeyWGbAclRDtHEhMBzqw2ctsWpdEHOwVgfruRu0VIVy01Ah10dd/iFajMHYU71L7IBWBOw==
glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.2:
glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@ -3463,18 +3576,6 @@ globals@^13.6.0:
dependencies:
type-fest "^0.20.2"
globby@^11.0.1:
version "11.0.3"
resolved "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz"
integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==
dependencies:
array-union "^2.1.0"
dir-glob "^3.0.1"
fast-glob "^3.1.1"
ignore "^5.1.4"
merge2 "^1.3.0"
slash "^3.0.0"
globby@^11.0.4:
version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
@ -3617,12 +3718,7 @@ ignore@^4.0.6:
resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
ignore@^5.1.4:
version "5.1.8"
resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
ignore@^5.2.0:
ignore@^5.1.8, ignore@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
@ -3700,6 +3796,17 @@ ip@^1.1.5:
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
iron-session@^6.1.3:
version "6.1.3"
resolved "https://registry.yarnpkg.com/iron-session/-/iron-session-6.1.3.tgz#c900102560e7d19541a9e6b8bbabc5436b01a230"
integrity sha512-o5ErwzAtTBKPtxo4nDmxOZAjK4Stku//5sFM0vac3/Px34530gTwnXoa8zwsC4/koqCtKY0yC0KF/1K+ZMGuHA==
dependencies:
"@hapi/iron" "^6.0.0"
"@types/cookie" "^0.5.1"
"@types/express" "^4.17.13"
"@types/node" "^16.11.7"
cookie "^0.5.0"
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
@ -4146,7 +4253,7 @@ lodash.truncate@^4.4.2:
resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz"
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21:
lodash@^4.17.11, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -4198,7 +4305,7 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.2, micromatch@^4.0.4:
micromatch@^4.0.4:
version "4.0.4"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz"
integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
@ -4719,11 +4826,6 @@ pngjs@^4.0.1:
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==
point-in-polygon@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357"
integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==
popmotion@11.0.3:
version "11.0.3"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
@ -4820,12 +4922,13 @@ pretty-format@^27.2.5, pretty-format@^27.5.1:
ansi-styles "^5.0.0"
react-is "^17.0.1"
prisma@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd"
integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg==
prisma@^3.13.0:
version "3.13.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.13.0.tgz#b11edd5631222ff1bf1d5324732d47801386aa8c"
integrity sha512-oO1auBnBtieGdiN+57IgsA9Vr7Sy4HkILi1KSaUG4mpKfEbnkTGnLOxAqjLed+K2nsG/GtE1tJBtB7JxN1a78Q==
dependencies:
"@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines" "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
ts-pattern "^4.0.1"
process-nextick-args@~2.0.0:
version "2.0.1"
@ -5111,11 +5214,16 @@ regexp.prototype.flags@^1.4.1:
call-bind "^1.0.2"
define-properties "^1.1.3"
regexpp@^3.0.0, regexpp@^3.1.0:
regexpp@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz"
integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
regexpp@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
regexpu-core@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz"
@ -5268,7 +5376,7 @@ semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.2.1, semver@^7.3.2, semver@^7.3.5:
semver@^7.2.1, semver@^7.3.5:
version "7.3.5"
resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
@ -5738,6 +5846,11 @@ ts-easing@^0.2.0:
resolved "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz"
integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==
ts-pattern@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/ts-pattern/-/ts-pattern-4.0.2.tgz#b36afdb2de1ec0224539dcb7cea3a57c41453b9f"
integrity sha512-eHqR/7A6fcw05vCOfnL6RwgGJbVi9G/YHTdYdjYmElhDdJ1SMn7pWs+6+YuxygaFwQS/g+cIDlu+UD8IVpur1A==
tsconfig-paths@^3.12.0, tsconfig-paths@^3.14.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
@ -5768,7 +5881,7 @@ tslib@^2.1.0:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tsutils@^3.17.1, tsutils@^3.21.0:
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz"
integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==