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", "extends": ["next/core-web-vitals"],
"plugins": ["simple-import-sort"], "plugins": ["simple-import-sort", "@typescript-eslint"],
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["plugin:@typescript-eslint/recommended"]
}
],
"rules": { "rules": {
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error", "simple-import-sort/exports": "error",

View file

@ -41,6 +41,7 @@ jobs:
- name: Set environment variables - name: Set environment variables
run: | run: |
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV
echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV
- name: Install dependencies - name: Install dependencies
run: yarn install --frozen-lockfile 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) [![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) [![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) ![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) 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 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 _See [configuration](#-configuration) to see what parameters are availble._
# 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
```
Build and run with `docker-compose` 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 cp sample.env .env
``` ```
Fill in the required environment variables. _See [configuration](#-configuration) to see what parameters are availble._
```
# 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
```
Install dependencies Install dependencies
@ -91,6 +72,19 @@ yarn build
yarn start 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 ## 👨‍💻 Contributors
If you would like to contribute to the development of the project please reach out first before spending significant time on it. 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"]; htmlType?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
type?: "default" | "primary" | "danger" | "link"; type?: "default" | "primary" | "danger" | "link";
form?: string; form?: string;
href?: string;
rounded?: boolean; rounded?: boolean;
title?: string; title?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
@ -27,7 +26,6 @@ const Button: React.ForwardRefRenderFunction<HTMLButtonElement, ButtonProps> = (
className, className,
icon, icon,
disabled, disabled,
href,
rounded, rounded,
...passThroughProps ...passThroughProps
}, },

View file

@ -17,7 +17,7 @@ const CompactButton: React.VoidFunctionComponent<CompactButtonProps> = ({
return ( return (
<button <button
type="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} onClick={onClick}
> >
{Icon ? <Icon className="h-3 w-3" /> : children} {Icon ? <Icon className="h-3 w-3" /> : children}

View file

@ -20,14 +20,14 @@ import {
} from "../components/forms"; } from "../components/forms";
import StandardLayout from "../components/standard-layout"; import StandardLayout from "../components/standard-layout";
import Steps from "../components/steps"; import Steps from "../components/steps";
import { useUserName } from "../components/user-name-context";
import { encodeDateOption } from "../utils/date-time-utils"; import { encodeDateOption } from "../utils/date-time-utils";
import { SessionProps, useSession, withSession } from "./session";
type StepName = "eventDetails" | "options" | "userDetails"; type StepName = "eventDetails" | "options" | "userDetails";
const steps: 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) { if (!v) {
throw new Error("Required value is missing"); 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 initialNewEventData: NewEventData = { currentStep: 0 };
const sessionStorageKey = "newEventFormData"; const sessionStorageKey = "newEventFormData";
const Page: NextPage<{ export interface CreatePollPageProps extends SessionProps {
title?: string; title?: string;
location?: string; location?: string;
description?: string; description?: string;
view?: "week" | "month"; view?: "week" | "month";
}> = ({ title, location, description, view }) => { }
const Page: NextPage<CreatePollPageProps> = ({
title,
location,
description,
view,
}) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const router = useRouter(); const router = useRouter();
const session = useSession();
const [persistedFormData, setPersistedFormData] = const [persistedFormData, setPersistedFormData] =
useSessionStorage<NewEventData>(sessionStorageKey, { useSessionStorage<NewEventData>(sessionStorageKey, {
currentStep: 0, currentStep: 0,
@ -59,6 +68,13 @@ const Page: NextPage<{
options: { options: {
view, view,
}, },
userDetails:
session.user?.isGuest === false
? {
name: session.user.name,
contact: session.user.email,
}
: undefined,
}); });
const [formData, setTransientFormData] = React.useState(persistedFormData); const [formData, setTransientFormData] = React.useState(persistedFormData);
@ -77,8 +93,6 @@ const Page: NextPage<{
const [isRedirecting, setIsRedirecting] = React.useState(false); const [isRedirecting, setIsRedirecting] = React.useState(false);
const [, setUserName] = useUserName();
const plausible = usePlausible(); const plausible = usePlausible();
const { mutate: createEventMutation, isLoading: isCreatingPoll } = const { mutate: createEventMutation, isLoading: isCreatingPoll } =
@ -101,7 +115,6 @@ const Page: NextPage<{
{ {
onSuccess: (poll) => { onSuccess: (poll) => {
setIsRedirecting(true); setIsRedirecting(true);
setUserName(poll.authorName);
plausible("Created poll", { plausible("Created poll", {
props: { props: {
numberOfOptions: formData.options?.options?.length, 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, CreateCommentPayload,
} from "../../api-client/create-comment"; } from "../../api-client/create-comment";
import { requiredString } from "../../utils/form-validation"; import { requiredString } from "../../utils/form-validation";
import Badge from "../badge";
import Button from "../button"; import Button from "../button";
import CompactButton from "../compact-button"; import CompactButton from "../compact-button";
import Dropdown, { DropdownItem } from "../dropdown"; import Dropdown, { DropdownItem } from "../dropdown";
@ -20,13 +21,13 @@ import DotsHorizontal from "../icons/dots-horizontal.svg";
import Trash from "../icons/trash.svg"; import Trash from "../icons/trash.svg";
import NameInput from "../name-input"; import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify"; 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 { usePreferences } from "../preferences/use-preferences";
import { useUserName } from "../user-name-context"; import { useSession } from "../session";
export interface DiscussionProps { export interface DiscussionProps {
pollId: string; pollId: string;
canDelete?: boolean;
} }
interface CommentForm { interface CommentForm {
@ -36,11 +37,9 @@ interface CommentForm {
const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
pollId, pollId,
canDelete,
}) => { }) => {
const { locale } = usePreferences(); const { locale } = usePreferences();
const getCommentsQueryKey = ["poll", pollId, "comments"]; const getCommentsQueryKey = ["poll", pollId, "comments"];
const [userName, setUserName] = useUserName();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: comments } = useQuery( const { data: comments } = useQuery(
getCommentsQueryKey, getCommentsQueryKey,
@ -64,6 +63,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
}, },
{ {
onSuccess: (newComment) => { onSuccess: (newComment) => {
session.refresh();
queryClient.setQueryData(getCommentsQueryKey, (comments) => { queryClient.setQueryData(getCommentsQueryKey, (comments) => {
if (Array.isArray(comments)) { if (Array.isArray(comments)) {
return [...comments, newComment]; return [...comments, newComment];
@ -75,6 +75,8 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
}, },
); );
const { poll } = usePoll();
const { mutate: deleteCommentMutation } = useMutation( const { mutate: deleteCommentMutation } = useMutation(
async (payload: { pollId: string; commentId: string }) => { async (payload: { pollId: string; commentId: string }) => {
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`); 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 } = const { register, setValue, control, handleSubmit, formState } =
useForm<CommentForm>({ useForm<CommentForm>({
defaultValues: { defaultValues: {
authorName: userName, authorName: "",
content: "", content: "",
}, },
}); });
React.useEffect(() => {
setValue("authorName", userName);
}, [setValue, userName]);
const handleDelete = React.useCallback( const handleDelete = React.useCallback(
(commentId: string) => { (commentId: string) => {
deleteCommentMutation({ pollId, commentId }); deleteCommentMutation({ pollId, commentId });
@ -124,6 +124,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{comments.map((comment) => { {comments.map((comment) => {
const canDelete =
poll.role === "admin" || session.ownsObject(comment);
return ( return (
<motion.div <motion.div
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
@ -137,12 +140,16 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
initial={{ scale: 0.8, y: 10 }} initial={{ scale: 0.8, y: 10 }}
animate={{ scale: 1, y: 0 }} animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.8 }} exit={{ scale: 0.8 }}
data-testid="comment"
className="w-fit rounded-xl border bg-white px-3 py-2 shadow-sm" className="w-fit rounded-xl border bg-white px-3 py-2 shadow-sm"
> >
<div className="flex items-center space-x-2"> <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"> <div className="mb-1">
<span className="mr-1">{comment.authorName}</span>
<span className="mr-1 text-slate-400">&bull;</span> <span className="mr-1 text-slate-400">&bull;</span>
<span className="text-sm text-slate-500"> <span className="text-sm text-slate-500">
{formatRelative( {formatRelative(
@ -189,7 +196,6 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
}, },
{ {
onSuccess: () => { onSuccess: () => {
setUserName(data.authorName);
setValue("content", ""); setValue("content", "");
resolve(data); resolve(data);
}, },
@ -201,23 +207,28 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
> >
<textarea <textarea
id="comment" id="comment"
placeholder="Add your comment…" placeholder="Thanks for the invite!"
className="input w-full py-2 pl-3 pr-4" className="input w-full py-2 pl-3 pr-4"
{...register("content", { validate: requiredString })} {...register("content", { validate: requiredString })}
/> />
<div className="mt-1 flex space-x-3"> <div className="mt-1 flex space-x-3">
<Controller <div>
name="authorName" <Controller
control={control} name="authorName"
rules={{ validate: requiredString }} key={session.user?.id}
render={({ field }) => <NameInput className="w-full" {...field} />} control={control}
/> rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput {...field} className="w-full" />
)}
/>
</div>
<Button <Button
htmlType="submit" htmlType="submit"
loading={formState.isSubmitting} loading={formState.isSubmitting}
type="primary" type="primary"
> >
Send Comment
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -1,4 +1,5 @@
import { import {
autoUpdate,
flip, flip,
FloatingPortal, FloatingPortal,
offset, offset,
@ -27,22 +28,28 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
trigger, trigger,
placement: preferredPlacement, placement: preferredPlacement,
}) => { }) => {
const { reference, floating, x, y, strategy, placement } = useFloating({ const { reference, floating, x, y, strategy, placement, refs, update } =
placement: preferredPlacement, useFloating({
middleware: [offset(5), flip()], strategy: "fixed",
}); placement: preferredPlacement,
middleware: [offset(5), flip()],
});
const animationOrigin = transformOriginByPlacement[placement]; 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 ( return (
<Menu> <Menu>
{({ open }) => ( {({ open }) => (
<> <>
<Menu.Button <Menu.Button as="div" className={className} ref={reference}>
as="div"
className={clsx("inline-block", className)}
ref={reference}
>
{trigger} {trigger}
</Menu.Button> </Menu.Button>
<FloatingPortal> <FloatingPortal>

View file

@ -7,7 +7,6 @@ import Chat from "@/components/icons/chat.svg";
import EmojiSad from "@/components/icons/emoji-sad.svg"; import EmojiSad from "@/components/icons/emoji-sad.svg";
import { showCrispChat } from "./crisp-chat"; import { showCrispChat } from "./crisp-chat";
import StandardLayout from "./standard-layout";
export interface ComponentProps { export interface ComponentProps {
icon?: React.ComponentType<{ className?: string }>; icon?: React.ComponentType<{ className?: string }>;
@ -21,31 +20,29 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
description, description,
}) => { }) => {
return ( return (
<StandardLayout> <div className="mx-auto flex h-full max-w-full items-center justify-center bg-gray-50 px-4 py-8 lg:w-[1024px]">
<div className="flex h-full max-w-full items-center justify-center bg-gray-50 px-4 py-8 lg:w-[1024px]"> <Head>
<Head> <title>{title}</title>
<title>{title}</title> <meta name="robots" content="noindex,nofollow" />
<meta name="robots" content="noindex,nofollow" /> </Head>
</Head> <div className="flex items-start">
<div className="flex items-start"> <div className="text-center">
<div className="text-center"> <Icon className="mb-4 inline-block w-24 text-slate-400" />
<Icon className="mb-4 inline-block w-24 text-slate-400" /> <div className="text-3xl font-bold uppercase text-indigo-500 ">
<div className="text-3xl font-bold uppercase text-indigo-500 "> {title}
{title} </div>
</div> <p>{description}</p>
<p>{description}</p> <div className="flex justify-center space-x-3">
<div className="flex justify-center space-x-3"> <Link href="/" passHref={true}>
<Link href="/" passHref={true}> <a className="btn-default">Go to home</a>
<a className="btn-default">Go to home</a> </Link>
</Link> <Button icon={<Chat />} onClick={showCrispChat}>
<Button icon={<Chat />} onClick={showCrispChat}> Start chat
Start chat </Button>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
</StandardLayout> </div>
); );
}; };

View file

@ -110,7 +110,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
<div <div
// onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element // onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
onMouseUp={props.onClick} 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={{ style={{
top: `calc(${props.style?.top}% + 4px)`, top: `calc(${props.style?.top}% + 4px)`,
height: `calc(${props.style?.height}% - 8px)`, height: `calc(${props.style?.height}% - 8px)`,
@ -126,6 +126,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
); );
}, },
week: { week: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: ({ date }: any) => { header: ({ date }: any) => {
const dateString = formatDateWithoutTime(date); const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => { const selectedOption = options.find((option) => {

View file

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

View file

@ -3,7 +3,7 @@ import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation"; import { requiredString, validEmail } from "../../utils/form-validation";
import { PollFormProps } from "./types"; import { PollFormProps } from "./types";
export interface UserDetailsData { export interface UserDetailsData {
@ -65,9 +65,7 @@ export const UserDetailsForm: React.VoidFunctionComponent<
})} })}
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
{...register("contact", { {...register("contact", {
validate: (value) => { validate: validEmail,
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
},
})} })}
/> />
</div> </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 DateCard from "../date-card";
import Score from "../poll/desktop-poll/score"; 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"; import VoteIcon from "../poll/vote-icon";
const sidebarWidth = 180; const sidebarWidth = 180;
@ -87,14 +87,11 @@ const PollDemo: React.VoidFunctionComponent = () => {
className="flex shrink-0 items-center px-4" className="flex shrink-0 items-center px-4"
style={{ width: sidebarWidth }} style={{ width: sidebarWidth }}
> >
<UserAvater <UserAvatar
className="mr-2"
color={participant.color} color={participant.color}
name={participant.name} name={participant.name}
showName={true}
/> />
<span className="truncate" title={participant.name}>
{participant.name}
</span>
</div> </div>
<div className="flex"> <div className="flex">
{options.map((_, i) => { {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"> <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> </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 { AnimatePresence, motion } from "framer-motion";
import * as React from "react"; import * as React from "react";
import X from "@/components/icons/x.svg";
import Button, { ButtonProps } from "../button"; import Button, { ButtonProps } from "../button";
export interface ModalProps { export interface ModalProps {
@ -16,6 +18,7 @@ export interface ModalProps {
content?: React.ReactNode; content?: React.ReactNode;
overlayClosable?: boolean; overlayClosable?: boolean;
visible?: boolean; visible?: boolean;
showClose?: boolean;
} }
const Modal: React.VoidFunctionComponent<ModalProps> = ({ const Modal: React.VoidFunctionComponent<ModalProps> = ({
@ -30,6 +33,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
onCancel, onCancel,
onOk, onOk,
visible, visible,
showClose,
}) => { }) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null); const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return ( return (
@ -53,48 +57,64 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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 <motion.div
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} 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"
> >
{content ?? ( <div className="mx-4 max-w-full overflow-hidden rounded-xl bg-white shadow-xl xs:rounded-xl">
<div className="max-w-lg p-4"> {showClose ? (
{title ? <Dialog.Title>{title}</Dialog.Title> : null} <button
{description ? ( 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"
<Dialog.Description>{description}</Dialog.Description> onClick={onCancel}
) : null} >
</div> <X className="h-4" />
)} </button>
{footer ?? ( ) : null}
<div className="flex h-14 items-center justify-end space-x-3 border-t bg-slate-50 px-4"> {content ?? (
{cancelText ? ( <div className="max-w-md p-6">
<Button {title ? (
ref={initialFocusRef} <Dialog.Title className="mb-2 font-medium">
onClick={() => { {title}
onCancel?.(); </Dialog.Title>
}} ) : null}
> {description ? (
{cancelText} <Dialog.Description className="m-0">
</Button> {description}
) : null} </Dialog.Description>
{okText ? ( ) : null}
<Button </div>
type="primary" )}
onClick={() => { {footer === undefined ? (
onOk?.(); <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 ? (
{...okButtonProps} <Button
> onClick={() => {
{okText} onCancel?.();
</Button> }}
) : null} >
</div> {cancelText}
)} </Button>
) : null}
{okText ? (
<Button
ref={initialFocusRef}
type="primary"
onClick={() => {
onOk?.();
}}
{...okButtonProps}
>
{okText}
</Button>
) : null}
</div>
) : null}
</div>
</motion.div> </motion.div>
</motion.div> </motion.div>
</Dialog> </Dialog>

View file

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

View file

@ -10,11 +10,13 @@ import {
} from "utils/date-time-utils"; } from "utils/date-time-utils";
import { usePreferences } from "./preferences/use-preferences"; import { usePreferences } from "./preferences/use-preferences";
import { useSession } from "./session";
import { useRequiredContext } from "./use-required-context"; import { useRequiredContext } from "./use-required-context";
type VoteType = "yes" | "no"; type VoteType = "yes" | "no";
type PollContextValue = { type PollContextValue = {
userAlreadyVoted: boolean;
poll: GetPollResponse; poll: GetPollResponse;
targetTimeZone: string; targetTimeZone: string;
setTargetTimeZone: (timeZone: string) => void; setTargetTimeZone: (timeZone: string) => void;
@ -43,6 +45,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
value: GetPollResponse; value: GetPollResponse;
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ value: poll, children }) => { }> = ({ value: poll, children }) => {
const { user } = useSession();
const [targetTimeZone, setTargetTimeZone] = const [targetTimeZone, setTargetTimeZone] =
React.useState(getBrowserTimeZone); React.useState(getBrowserTimeZone);
@ -85,7 +88,16 @@ export const PollContextProvider: React.VoidFunctionComponent<{
return participant; return participant;
}; };
const userAlreadyVoted = user
? poll.participants.some((participant) =>
user.isGuest
? participant.guestId === user.id
: participant.userId === user.id,
)
: false;
return { return {
userAlreadyVoted,
poll, poll,
getVotesForOption: (optionId: string) => { getVotesForOption: (optionId: string) => {
// TODO (Luke Vella) [2022-04-16]: Build an index instead // TODO (Luke Vella) [2022-04-16]: Build an index instead
@ -109,7 +121,14 @@ export const PollContextProvider: React.VoidFunctionComponent<{
targetTimeZone, targetTimeZone,
setTargetTimeZone, setTargetTimeZone,
}; };
}, [locale, participantById, participantsByOptionId, poll, targetTimeZone]); }, [
locale,
participantById,
participantsByOptionId,
poll,
targetTimeZone,
user,
]);
return ( return (
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider> <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 { UserAvatarProvider } from "./poll/user-avatar";
import { PollContextProvider, usePoll } from "./poll-context"; import { PollContextProvider, usePoll } from "./poll-context";
import Popover from "./popover"; import Popover from "./popover";
import { useSession } from "./session";
import Sharing from "./sharing"; import Sharing from "./sharing";
import StandardLayout from "./standard-layout"; import StandardLayout from "./standard-layout";
import { useUserName } from "./user-name-context";
const Discussion = React.lazy(() => import("@/components/discussion")); const Discussion = React.lazy(() => import("@/components/discussion"));
@ -45,7 +45,7 @@ const PollInner: NextPage = () => {
} }
}); });
const [, setUserName] = useUserName(); const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const plausible = usePlausible(); const plausible = usePlausible();
@ -61,15 +61,20 @@ const PollInner: NextPage = () => {
{ {
onSuccess: () => { onSuccess: () => {
toast.success("Your poll has been verified"); toast.success("Your poll has been verified");
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
queryClient.setQueryData(["getPoll", poll.urlId], { queryClient.setQueryData(["getPoll", poll.urlId], {
...poll, ...poll,
verified: true, verified: true,
}); });
session.refresh();
plausible("Verified email"); 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"> <div className="mb-4 lg:mb-8">
<PollComponent pollId={poll.urlId} highScore={highScore} /> <PollComponent pollId={poll.urlId} highScore={highScore} />
</div> </div>
<Discussion <Discussion pollId={poll.urlId} />
pollId={poll.urlId}
canDelete={poll.role === "admin"}
/>
</React.Suspense> </React.Suspense>
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ const TimeSlotOptions: React.VoidFunctionComponent<TimeSlotOptionsProps> = ({
{Object.entries(grouped).map(([day, options]) => { {Object.entries(grouped).map(([day, options]) => {
return ( return (
<div key={day}> <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} {day}
</div> </div>
<PollOptions <PollOptions

View file

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

View file

@ -10,77 +10,72 @@ import { usePoll } from "../poll-context";
import Tooltip from "../tooltip"; import Tooltip from "../tooltip";
import { useUpdatePollMutation } from "./mutations"; import { useUpdatePollMutation } from "./mutations";
export interface NotificationsToggleProps {} const NotificationsToggle: React.VoidFunctionComponent = () => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);
const NotificationsToggle: React.VoidFunctionComponent<NotificationsToggleProps> = const { mutate: updatePollMutation } = useUpdatePollMutation();
() => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation(); const plausible = usePlausible();
return (
const plausible = usePlausible(); <Tooltip
return ( content={
<Tooltip poll.verified ? (
content={ poll.notifications ? (
poll.verified ? ( <div>
poll.notifications ? ( <div className="font-medium text-indigo-300">
<div> Notifications are on
<div className="font-medium text-indigo-300">
Notifications are on
</div>
<div className="max-w-sm">
<Trans
t={t}
i18nKey="notificationsOnDescription"
values={{
email: poll.user.email,
}}
components={{
b: (
<span className="whitespace-nowrap font-mono font-medium text-indigo-300 " />
),
}}
/>
</div>
</div> </div>
) : ( <div className="max-w-sm">
"Notifications are off" <Trans
) t={t}
i18nKey="notificationsOnDescription"
values={{
email: poll.user.email,
}}
components={{
b: (
<span className="whitespace-nowrap font-mono font-medium text-indigo-300 " />
),
}}
/>
</div>
</div>
) : ( ) : (
"You need to verify your email to turn on notifications" "Notifications are off"
) )
} ) : (
> "You need to verify your email to turn on notifications"
<Button )
loading={isUpdatingNotifications} }
icon={ >
poll.verified && poll.notifications ? <Bell /> : <BellCrossed /> <Button
} loading={isUpdatingNotifications}
disabled={!poll.verified} icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
onClick={() => { disabled={!poll.verified}
setIsUpdatingNotifications(true); onClick={() => {
updatePollMutation( setIsUpdatingNotifications(true);
{ updatePollMutation(
notifications: !poll.notifications, {
notifications: !poll.notifications,
},
{
onSuccess: ({ notifications }) => {
plausible(
notifications
? "Turned notifications on"
: "Turned notifications off",
);
setIsUpdatingNotifications(false);
}, },
{ },
onSuccess: ({ notifications }) => { );
plausible( }}
notifications />
? "Turned notifications on" </Tooltip>
: "Turned notifications off", );
); };
setIsUpdatingNotifications(false);
},
},
);
}}
/>
</Tooltip>
);
};
export default NotificationsToggle; export default NotificationsToggle;

View file

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

View file

@ -2,11 +2,15 @@ import clsx from "clsx";
import * as React from "react"; import * as React from "react";
import { stringToValue } from "utils/string-to-value"; import { stringToValue } from "utils/string-to-value";
import Badge from "../badge";
export interface UserAvaterProps { export interface UserAvaterProps {
name: string; name: string;
className?: string; className?: string;
size?: "default" | "large"; size?: "default" | "large";
color?: string; color?: string;
showName?: boolean;
isYou?: boolean;
} }
const UserAvatarContext = const UserAvatarContext =
@ -68,7 +72,7 @@ export const UserAvatarProvider: React.VoidFunctionComponent<{
); );
}; };
const UserAvater: React.VoidFunctionComponent<UserAvaterProps> = ({ const UserAvatarInner: React.VoidFunctionComponent<UserAvaterProps> = ({
name, name,
className, className,
color: colorOverride, 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"; import { transformOriginByPlacement } from "utils/constants";
interface PopoverProps { interface PopoverProps {
trigger: React.ReactNode; trigger: React.ReactElement;
children?: React.ReactNode; children?: React.ReactNode;
placement?: Placement; placement?: Placement;
} }
@ -37,11 +37,7 @@ const Popover: React.VoidFunctionComponent<PopoverProps> = ({
<HeadlessPopover as={React.Fragment}> <HeadlessPopover as={React.Fragment}>
{({ open }) => ( {({ open }) => (
<> <>
<HeadlessPopover.Button <HeadlessPopover.Button ref={reference} as="div">
ref={reference}
as="div"
className={clsx("inline-block")}
>
{trigger} {trigger}
</HeadlessPopover.Button> </HeadlessPopover.Button>
<FloatingPortal> <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 clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import Menu from "@/components/icons/menu.svg"; import Menu from "@/components/icons/menu.svg";
import User from "@/components/icons/user.svg";
import Logo from "../public/logo.svg"; import Logo from "../public/logo.svg";
import Dropdown, { DropdownItem, DropdownProps } from "./dropdown";
import Adjustments from "./icons/adjustments.svg"; import Adjustments from "./icons/adjustments.svg";
import Cash from "./icons/cash.svg"; import Cash from "./icons/cash.svg";
import DotsVertical from "./icons/dots-vertical.svg";
import Github from "./icons/github.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 Pencil from "./icons/pencil.svg";
import Question from "./icons/question-mark-circle.svg";
import Support from "./icons/support.svg"; import Support from "./icons/support.svg";
import Twitter from "./icons/twitter.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 Popover from "./popover";
import Preferences from "./preferences"; import Preferences from "./preferences";
import { useSession } from "./session";
const HomeLink = () => { const HomeLink = () => {
return ( return (
<Link href="/"> <Link href="/">
<a> <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> </a>
</Link> </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 }> = ({ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
className, className,
}) => { }) => {
@ -31,7 +124,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<div className={clsx("space-y-1", className)}> <div className={clsx("space-y-1", className)}>
<Link href="/new"> <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"> <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> <span className="inline-block">New Poll</span>
</a> </a>
</Link> </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<{ const StandardLayout: React.VoidFunctionComponent<{
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ children, ...rest }) => { }> = ({ children, ...rest }) => {
const { user } = useSession();
const [loginModal, openLoginModal] = useModal({
footer: null,
overlayClosable: true,
showClose: true,
content: <LoginForm />,
});
return ( return (
<div <div
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row" className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
{...rest} {...rest}
> >
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between border-b px-4 lg:hidden"> {loginModal}
<div> <MobileNavigation openLoginModal={openLoginModal} />
<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>
<div className="hidden grow px-4 pt-6 pb-5 lg:block"> <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="sticky top-6 float-right w-48 items-start">
<div className="mb-8 grow-0 px-2"> <div className="mb-8 px-3">
<HomeLink /> <HomeLink />
</div> </div>
<div className="mb-4 block w-full shrink-0 grow items-center pb-4 text-base"> <div className="mb-4">
<div className="mb-4"> <Link href="/new">
<Link href="/new"> <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">
<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 group-hover:text-indigo-500 group-hover:opacity-100" />
<Pencil className="h-5 opacity-75" /> <span className="grow text-left">New Poll</span>
<span className="inline-block">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"
rel="noreferrer"
>
<Support className="h-5 opacity-75" />
<span className="inline-block">Support</span>
</a> </a>
<Popover </Link>
placement="right-start" <a
trigger={ target="_blank"
<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"> href="https://support.rallly.co"
<Adjustments className="h-5 opacity-75" /> 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"
<span className="inline-block">Preferences</span> rel="noreferrer"
</button> >
} <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="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"
> >
<Preferences /> <Login className="h-5 opacity-75 group-hover:text-indigo-500 group-hover:opacity-100" />
</Popover> <span className="grow text-left">Login</span>
</div> </button>
)}
</div> </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> </div>
<div className="min-w-0 grow"> <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} {children}
</div> </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"> <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} checked={checked}
onChange={onChange} onChange={onChange}
className={clsx( 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-gray-200": !checked,
"bg-green-500": checked, "bg-green-500": checked,

View file

@ -5,6 +5,7 @@ import {
offset, offset,
Placement, Placement,
shift, shift,
useDismiss,
useFloating, useFloating,
useHover, useHover,
useInteractions, useInteractions,
@ -38,6 +39,8 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
const { const {
reference, reference,
floating, floating,
refs,
update,
x, x,
y, y,
strategy, strategy,
@ -78,8 +81,17 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
useRole(context, { useRole(context, {
role: "tooltip", 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 ( return (
<> <>
<span <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"; import React from "react";
export const useRequiredContext = <T extends any>( export const useRequiredContext = <T>(
context: React.Context<T | null>, context: React.Context<T | null>,
errorMessage?: string, 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 { interface ProcessEnv {
DATABASE_URL: string; DATABASE_URL: string;
NODE_ENV: "development" | "production"; NODE_ENV: "development" | "production";
JWT_SECRET: string; SECRET_PASSWORD: string;
MAINTENANCE_MODE?: "true"; MAINTENANCE_MODE?: "true";
PLAUSIBLE_DOMAIN?: string; PLAUSIBLE_DOMAIN?: string;
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string; NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;

View file

@ -1,8 +1,8 @@
import "react-i18next"; import "react-i18next";
import app from "./public/locales/en/app.json"; import app from "../public/locales/en/app.json";
import homepage from "./public/locales/en/homepage.json"; import homepage from "../public/locales/en/homepage.json";
import support from "./public/locales/en/support.json"; import support from "../public/locales/en/support.json";
declare module "next-i18next" { declare module "next-i18next" {
interface Resources { 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", "analyze": "ANALYZE=true next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:tsc": "tsc --noEmit",
"test": "playwright test" "test": "playwright test"
}, },
"dependencies": { "dependencies": {
"@floating-ui/react-dom-interactions": "^0.3.1", "@floating-ui/react-dom-interactions": "^0.4.0",
"@headlessui/react": "^1.5.0", "@headlessui/react": "^1.5.0",
"@next/bundle-analyzer": "^12.1.0", "@next/bundle-analyzer": "^12.1.0",
"@prisma/client": "^3.12.0", "@prisma/client": "^3.13.0",
"@sentry/nextjs": "^6.19.3", "@sentry/nextjs": "^6.19.3",
"@svgr/webpack": "^6.2.1", "@svgr/webpack": "^6.2.1",
"@tailwindcss/forms": "^0.4.0", "@tailwindcss/forms": "^0.4.0",
@ -26,6 +27,7 @@
"date-fns-tz": "^1.2.2", "date-fns-tz": "^1.2.2",
"eta": "^1.12.3", "eta": "^1.12.3",
"framer-motion": "^6.2.9", "framer-motion": "^6.2.9",
"iron-session": "^6.1.3",
"jose": "^4.5.1", "jose": "^4.5.1",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -35,7 +37,7 @@
"next-i18next": "^10.5.0", "next-i18next": "^10.5.0",
"next-plausible": "^3.1.9", "next-plausible": "^3.1.9",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"prisma": "^3.12.0", "prisma": "^3.13.0",
"react": "17.0.2", "react": "17.0.2",
"react-big-calendar": "^0.38.9", "react-big-calendar": "^0.38.9",
"react-dom": "17.0.2", "react-dom": "17.0.2",
@ -61,8 +63,8 @@
"@types/react-dom": "^17.0.13", "@types/react-dom": "^17.0.13",
"@types/react-linkify": "^1.0.1", "@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1", "@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^5.21.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"eslint": "^7.26.0", "eslint": "^7.26.0",
"eslint-config-next": "12.1.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 "tailwindcss/tailwind.css";
import "../style.css"; import "../style.css";
import axios from "axios";
import { NextPage } from "next"; import { NextPage } from "next";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -10,30 +11,27 @@ import { appWithTranslation } from "next-i18next";
import PlausibleProvider from "next-plausible"; import PlausibleProvider from "next-plausible";
import toast, { Toaster } from "react-hot-toast"; import toast, { Toaster } from "react-hot-toast";
import { MutationCache, QueryClient, QueryClientProvider } from "react-query"; import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
import { useSessionStorage } from "react-use";
import ModalProvider from "@/components/modal/modal-provider"; import ModalProvider from "@/components/modal/modal-provider";
import PreferencesProvider from "@/components/preferences/preferences-provider"; import PreferencesProvider from "@/components/preferences/preferences-provider";
import { UserNameContext } from "../components/user-name-context";
const CrispChat = dynamic(() => import("@/components/crisp-chat"), { const CrispChat = dynamic(() => import("@/components/crisp-chat"), {
ssr: false, ssr: false,
}); });
const queryClient = new QueryClient({ const queryClient = new QueryClient({
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: () => { onError: (error) => {
toast.error( if (axios.isAxiosError(error) && error.response?.status === 500) {
"Uh oh! Something went wrong. The issue has been logged and we'll fix it as soon as possible. Please try again later.", 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 MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
const sessionUserName = useSessionStorage<string>("userName", "");
return ( return (
<PlausibleProvider <PlausibleProvider
domain="rallly.co" domain="rallly.co"
@ -53,9 +51,7 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
<CrispChat /> <CrispChat />
<Toaster /> <Toaster />
<ModalProvider> <ModalProvider>
<UserNameContext.Provider value={sessionUserName}> <Component {...pageProps} />
<Component {...pageProps} />
</UserNameContext.Provider>
</ModalProvider> </ModalProvider>
</QueryClientProvider> </QueryClientProvider>
</PreferencesProvider> </PreferencesProvider>

View file

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

View file

@ -1,6 +1,6 @@
import { GetPollApiResponse } from "api-client/get-poll"; import { GetPollApiResponse } from "api-client/get-poll";
import { NextApiRequest, NextApiResponse } from "next"; 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 { LegacyPoll } from "utils/legacy-utils";
import { getMongoClient } from "utils/mongodb-client"; import { getMongoClient } from "utils/mongodb-client";
import { nanoid } from "utils/nanoid"; import { nanoid } from "utils/nanoid";
@ -53,6 +53,7 @@ export default async function handler(
const votes: Array<{ optionId: string; participantId: string }> = []; const votes: Array<{ optionId: string; participantId: string }> = [];
newParticipants?.forEach((p, i) => { newParticipants?.forEach((p, i) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const legacyVotes = legacyPoll.participants![i].votes; const legacyVotes = legacyPoll.participants![i].votes;
legacyVotes?.forEach((v, j) => { legacyVotes?.forEach((v, j) => {
if (v) { if (v) {
@ -90,7 +91,6 @@ export default async function handler(
}, },
}, },
notifications: legacyPoll.creator.allowNotifications, notifications: legacyPoll.creator.allowNotifications,
verificationCode: legacyPoll.__private.verificationCode,
options: { options: {
createMany: { createMany: {
data: newOptions, data: newOptions,
@ -157,7 +157,7 @@ export default async function handler(
}); });
return res.json({ return res.json({
...exclude(poll, "verificationCode"), ...poll,
role: "admin", role: "admin",
urlId: poll.urlId, urlId: poll.urlId,
pollId: 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,85 +1,56 @@
import absoluteUrl from "utils/absolute-url"; import { createGuestUser, withSessionRoute } from "utils/auth";
import { prisma } from "../../../../../db"; import { prisma } from "../../../../../db";
import { import { sendNotification, withLink } from "../../../../../utils/api-utils";
getAdminLink,
sendEmailTemplate,
withLink,
} from "../../../../../utils/api-utils";
export default withLink(async (req, res, link) => { export default withSessionRoute(
switch (req.method) { withLink(async ({ req, res, link }) => {
case "GET": { switch (req.method) {
const comments = await prisma.comment.findMany({ case "GET": {
where: { const comments = await prisma.comment.findMany({
pollId: link.pollId, where: {
}, pollId: link.pollId,
orderBy: [
{
createdAt: "asc",
}, },
], orderBy: [
}); {
createdAt: "asc",
},
],
});
return res.json({ comments }); return res.json({ comments });
} }
case "POST": { case "POST": {
const newComment = await prisma.comment.create({ if (!req.session.user) {
data: { await createGuestUser(req);
content: req.body.content,
pollId: link.pollId,
authorName: req.body.authorName,
},
});
const poll = await prisma.poll.findUnique({
where: {
urlId: link.pollId,
},
include: {
links: true,
user: 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-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}`);
} }
const newComment = await prisma.comment.create({
data: {
content: req.body.content,
pollId: link.pollId,
authorName: req.body.authorName,
userId: req.session.user?.isGuest
? undefined
: req.session.user?.id,
guestId: req.session.user?.isGuest
? req.session.user.id
: undefined,
},
});
await sendNotification(req, link.pollId, {
type: "newComment",
authorName: newComment.authorName,
});
return res.json(newComment);
} }
return res.json(newComment); default:
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
} }
}),
default: );
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
});

View file

@ -1,165 +1,154 @@
import { GetPollApiResponse } from "api-client/get-poll"; import { GetPollApiResponse } from "api-client/get-poll";
import { NextApiResponse } from "next";
import { resetDates } from "utils/legacy-utils"; import { resetDates } from "utils/legacy-utils";
import { UpdatePollPayload } from "../../../../api-client/update-poll"; import { UpdatePollPayload } from "../../../../api-client/update-poll";
import { prisma } from "../../../../db"; import { prisma } from "../../../../db";
import { exclude, withLink } from "../../../../utils/api-utils"; import { withLink } from "../../../../utils/api-utils";
export default withLink( export default withLink<
async ( GetPollApiResponse | { status: number; message: string }
req, >(async ({ req, res, link }) => {
res: NextApiResponse< const pollId = link.pollId;
GetPollApiResponse | { status: number; message: string }
>,
link,
) => {
const pollId = link.pollId;
switch (req.method) { switch (req.method) {
case "GET": { case "GET": {
const poll = await prisma.poll.findUnique({ const poll = await prisma.poll.findUnique({
where: { where: {
urlId: pollId, urlId: pollId,
}, },
include: { include: {
options: { options: {
include: { include: {
votes: true, votes: true,
},
orderBy: {
value: "asc",
},
}, },
participants: { orderBy: {
include: { value: "asc",
votes: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
}, },
user: true,
links: link.role === "admin",
}, },
}); participants: {
include: {
if (!poll) { votes: true,
return res
.status(404)
.json({ status: 404, message: "Poll not found" });
}
if (
poll.legacy &&
// has converted options without timezone
poll.options.every(({ value }) => value.indexOf("T") === -1)
) {
// We need to reset the dates for polls that lost their timezone data because some users
// of the old version will end up seeing the wrong dates
const fixedPoll = await resetDates(poll.urlId);
if (fixedPoll) {
return res.json({
...exclude(fixedPoll, "verificationCode"),
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
}
return res.json({
...exclude(poll, "verificationCode"),
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
case "PATCH": {
if (link.role !== "admin") {
return res
.status(401)
.json({ status: 401, message: "Permission denied" });
}
const payload: Partial<UpdatePollPayload> = req.body;
if (payload.optionsToDelete && payload.optionsToDelete.length > 0) {
await prisma.option.deleteMany({
where: {
pollId,
id: {
in: payload.optionsToDelete,
},
}, },
}); orderBy: [
} {
if (payload.optionsToAdd && payload.optionsToAdd.length > 0) { createdAt: "desc",
await prisma.option.createMany({
data: payload.optionsToAdd.map((optionValue) => ({
value: optionValue,
pollId,
})),
});
}
const poll = await prisma.poll.update({
where: {
urlId: pollId,
},
data: {
title: payload.title,
location: payload.location,
description: payload.description,
timeZone: payload.timeZone,
notifications: payload.notifications,
closed: payload.closed,
},
include: {
options: {
include: {
votes: true,
}, },
orderBy: { { name: "desc" },
value: "asc", ],
},
},
participants: {
include: {
votes: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
user: true,
links: true,
}, },
}); user: true,
links: link.role === "admin",
},
});
if (!poll) { if (!poll) {
return res return res.status(404).json({ status: 404, message: "Poll not found" });
.status(404)
.json({ status: 404, message: "Poll not found" });
}
return res.json({
...exclude(poll, "verificationCode"),
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
} }
default: if (
return res poll.legacy &&
.status(405) // has converted options without timezone
.json({ status: 405, message: "Method not allowed" }); poll.options.every(({ value }) => value.indexOf("T") === -1)
) {
// We need to reset the dates for polls that lost their timezone data because some users
// of the old version will end up seeing the wrong dates
const fixedPoll = await resetDates(poll.urlId);
if (fixedPoll) {
return res.json({
...fixedPoll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
}
return res.json({
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
} }
}, case "PATCH": {
); if (link.role !== "admin") {
return res
.status(401)
.json({ status: 401, message: "Permission denied" });
}
const payload: Partial<UpdatePollPayload> = req.body;
if (payload.optionsToDelete && payload.optionsToDelete.length > 0) {
await prisma.option.deleteMany({
where: {
pollId,
id: {
in: payload.optionsToDelete,
},
},
});
}
if (payload.optionsToAdd && payload.optionsToAdd.length > 0) {
await prisma.option.createMany({
data: payload.optionsToAdd.map((optionValue) => ({
value: optionValue,
pollId,
})),
});
}
const poll = await prisma.poll.update({
where: {
urlId: pollId,
},
data: {
title: payload.title,
location: payload.location,
description: payload.description,
timeZone: payload.timeZone,
notifications: payload.notifications,
closed: payload.closed,
},
include: {
options: {
include: {
votes: true,
},
orderBy: {
value: "asc",
},
},
participants: {
include: {
votes: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
user: true,
links: true,
},
});
if (!poll) {
return res.status(404).json({ status: 404, message: "Poll not found" });
}
return res.json({
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
default:
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
});

View file

@ -1,7 +1,7 @@
import { prisma } from "../../../../../db"; import { prisma } from "../../../../../db";
import { getQueryParam, withLink } from "../../../../../utils/api-utils"; 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 participantId = getQueryParam(req, "participantId");
const pollId = link.pollId; const pollId = link.pollId;

View file

@ -1,81 +1,54 @@
import absoluteUrl from "utils/absolute-url"; import { createGuestUser, withSessionRoute } from "utils/auth";
import { AddParticipantPayload } from "../../../../../api-client/add-participant"; import { AddParticipantPayload } from "../../../../../api-client/add-participant";
import { prisma } from "../../../../../db"; import { prisma } from "../../../../../db";
import { import { sendNotification, withLink } from "../../../../../utils/api-utils";
getAdminLink,
sendEmailTemplate,
withLink,
} from "../../../../../utils/api-utils";
export default withLink(async (req, res, link) => { export default withSessionRoute(
switch (req.method) { withLink(async ({ req, res, link }) => {
case "POST": { switch (req.method) {
const payload: AddParticipantPayload = req.body; case "POST": {
const payload: AddParticipantPayload = req.body;
const participant = await prisma.participant.create({ if (!req.session.user) {
data: { await createGuestUser(req);
pollId: link.pollId, }
name: payload.name,
votes: { const participant = await prisma.participant.create({
createMany: { data: {
data: payload.votes.map((optionId) => ({ pollId: link.pollId,
optionId, name: payload.name,
pollId: link.pollId, 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) => ({
optionId,
pollId: link.pollId,
})),
},
}, },
}, },
}, include: {
include: { votes: true,
votes: true, },
}, });
});
const poll = await prisma.poll.findUnique({ await sendNotification(req, link.pollId, {
where: { type: "newParticipant",
urlId: link.pollId, participantName: participant.name,
}, });
include: {
user: true,
links: true,
},
});
if (poll?.notifications && poll.verified && !poll.demo) { return res.json(participant);
// 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,
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}`);
}
} }
default:
return res.json(participant); return res.status(405).json({ ok: 1 });
} }
default: }),
return res.status(405).json({ ok: 1 }); );
}
});

View file

@ -1,72 +1,109 @@
import absoluteUrl from "utils/absolute-url"; import absoluteUrl from "utils/absolute-url";
import {
createToken,
decryptToken,
mergeGuestsIntoUser as mergeUsersWithGuests,
withSessionRoute,
} from "utils/auth";
import { prisma } from "../../../../db"; import { prisma } from "../../../../db";
import { sendEmailTemplate, withLink } from "../../../../utils/api-utils"; import { sendEmailTemplate, withLink } from "../../../../utils/api-utils";
export default withLink(async (req, res, link) => { export default withSessionRoute(
if (req.method === "POST") { withLink(async ({ req, res, link }) => {
if (link.role !== "admin") { if (req.method === "POST") {
return res if (link.role !== "admin") {
.status(401) return res
.json({ status: 401, message: "Only admins can verify polls" }); .status(401)
} .json({ status: 401, message: "Only admins can verify polls" });
const verificationCode = req.body ? req.body.verificationCode : undefined;
if (!verificationCode) {
const poll = await prisma.poll.findUnique({
where: {
urlId: link.pollId,
},
include: {
user: true,
},
});
if (!poll) {
return res.status(404).json({ status: 404, message: "Poll not found" });
} }
const verificationCode = req.body ? req.body.verificationCode : undefined;
const homePageUrl = absoluteUrl(req).origin; if (!verificationCode) {
const pollUrl = `${homePageUrl}/admin/${link.urlId}`; const poll = await prisma.poll.findUnique({
const verifyEmailUrl = `${pollUrl}?code=${poll.verificationCode}`; where: {
await sendEmailTemplate({
templateName: "new-poll",
to: poll.user.email,
subject: `Rallly: ${poll.title} - Verify your email address`,
templateVars: {
title: poll.title,
name: poll.user.name,
pollUrl,
verifyEmailUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
return res.send("ok");
}
try {
await prisma.poll.update({
where: {
urlId_verificationCode: {
urlId: link.pollId, urlId: link.pollId,
verificationCode,
}, },
}, include: {
data: { user: true,
verified: true, },
}, });
});
return res.send("ok");
} catch {
console.error(
`Failed to verify poll "${link.pollId}" with code ${verificationCode}`,
);
return res
.status(500)
.json({ status: 500, message: "Could not verify poll" });
}
}
return res.status(405).json({ status: 405, message: "Invalid http method" }); if (!poll) {
}); return res
.status(404)
.json({ status: 404, message: "Poll not found" });
}
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${link.urlId}`;
const token = await createToken({
pollId: link.pollId,
});
const verifyEmailUrl = `${pollUrl}?code=${token}`;
await sendEmailTemplate({
templateName: "new-poll",
to: poll.user.email,
subject: `Rallly: ${poll.title} - Verify your email address`,
templateVars: {
title: poll.title,
name: poll.user.name,
pollUrl,
verifyEmailUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
return res.send("ok");
}
try {
const { pollId } = await decryptToken<{
pollId: string;
}>(verificationCode);
if (pollId !== link.pollId) {
res.status(401).json({
status: 401,
message: "Invalid token",
});
return;
}
const poll = await prisma.poll.update({
where: {
urlId: pollId,
},
data: {
verified: true,
},
include: { user: true },
});
// If logged in as guest, we update all participants
// and comments by this guest to the user that we just authenticated
if (req.session.user?.isGuest) {
await mergeUsersWithGuests(poll.user.id, [req.session.user.id]);
}
req.session.user = {
id: poll.user.id,
isGuest: false,
name: poll.user.name,
email: poll.user.email,
};
await req.session.save();
return res.send("ok");
} catch (e) {
console.error(e);
return res
.status(500)
.json({ status: 500, message: "Could not verify poll" });
}
}
return res
.status(405)
.json({ status: 405, message: "Invalid http method" });
}),
);

View file

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

View file

@ -1,23 +1,20 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendEmailTemplate } from "utils/api-utils"; import { sendEmailTemplate } from "utils/api-utils";
import { createToken, withSessionRoute } from "utils/auth";
import { nanoid } from "utils/nanoid"; import { nanoid } from "utils/nanoid";
import { CreatePollPayload } from "../../../api-client/create-poll"; import { CreatePollPayload } from "../../../api-client/create-poll";
import { prisma } from "../../../db"; import { prisma } from "../../../db";
import absoluteUrl from "../../../utils/absolute-url"; import absoluteUrl from "../../../utils/absolute-url";
export default async function handler( export default withSessionRoute(async (req, res) => {
req: NextApiRequest,
res: NextApiResponse,
) {
switch (req.method) { switch (req.method) {
case "POST": { case "POST": {
const adminUrlId = await nanoid(); const adminUrlId = await nanoid();
const payload: CreatePollPayload = req.body; const payload: CreatePollPayload = req.body;
const poll = await prisma.poll.create({ const poll = await prisma.poll.create({
data: { data: {
urlId: await nanoid(), urlId: await nanoid(),
verificationCode: await nanoid(),
title: payload.title, title: payload.title,
type: payload.type, type: payload.type,
timeZone: payload.timeZone, timeZone: payload.timeZone,
@ -25,6 +22,9 @@ export default async function handler(
description: payload.description, description: payload.description,
authorName: payload.user.name, authorName: payload.user.name,
demo: payload.demo, demo: payload.demo,
verified:
req.session.user?.isGuest === false &&
req.session.user.email === payload.user.email,
user: { user: {
connectOrCreate: { connectOrCreate: {
where: { where: {
@ -62,22 +62,41 @@ export default async function handler(
const homePageUrl = absoluteUrl(req).origin; const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminUrlId}`; const pollUrl = `${homePageUrl}/admin/${adminUrlId}`;
const verifyEmailUrl = `${pollUrl}?code=${poll.verificationCode}`;
try { try {
await sendEmailTemplate({ if (poll.verified) {
templateName: "new-poll", await sendEmailTemplate({
to: payload.user.email, templateName: "new-poll-verified",
subject: `Rallly: ${poll.title} - Verify your email address`, to: payload.user.email,
templateVars: { subject: `Rallly: ${poll.title}`,
title: poll.title, templateVars: {
name: payload.user.name, title: poll.title,
pollUrl, name: payload.user.name,
verifyEmailUrl, pollUrl,
homePageUrl, homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL, supportEmail: process.env.SUPPORT_EMAIL,
}, },
}); });
} else {
const verificationCode = await createToken({
pollId: poll.urlId,
});
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
await sendEmailTemplate({
templateName: "new-poll",
to: payload.user.email,
subject: `Rallly: ${poll.title} - Verify your email address`,
templateVars: {
title: poll.title,
name: payload.user.name,
pollUrl,
verifyEmailUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@ -85,6 +104,5 @@ export default async function handler(
return res.json({ urlId: adminUrlId, authorName: poll.authorName }); return res.json({ urlId: adminUrlId, authorName: poll.authorName });
} }
default: 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 { GetServerSideProps } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { serverSideTranslations } from "next-i18next/serverSideTranslations"; 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", locale = "en",
query, query,
req,
}) => { }) => {
return { return {
props: { props: {
...(await serverSideTranslations(locale, ["app"])), ...(await serverSideTranslations(locale, ["app"])),
...query, ...query,
user: req.session.user ?? null,
}, },
}; };
}; };
export const getServerSideProps = withSessionSsr(getProps);
// We disable SSR because the data on this page relies on sessionStore // We disable SSR because the data on this page relies on sessionStore
export default dynamic(() => import("@/components/create-poll"), { export default dynamic(() => import("@/components/create-poll"), {
ssr: false, ssr: false,

View file

@ -7,16 +7,18 @@ import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { usePlausible } from "next-plausible"; import { usePlausible } from "next-plausible";
import React from "react"; import React from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { withSessionSsr } from "utils/auth";
import ErrorPage from "@/components/error-page"; import ErrorPage from "@/components/error-page";
import FullPageLoader from "@/components/full-page-loader"; import FullPageLoader from "@/components/full-page-loader";
import { SessionProps, withSession } from "@/components/session";
import { GetPollResponse } from "../api-client/get-poll"; import { GetPollResponse } from "../api-client/get-poll";
import Custom404 from "./404"; import Custom404 from "./404";
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false }); const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
const PollPageLoader: NextPage = () => { const PollPageLoader: NextPage<SessionProps> = () => {
const { query } = useRouter(); const { query } = useRouter();
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const urlId = query.urlId as string; const urlId = query.urlId as string;
@ -71,29 +73,30 @@ const PollPageLoader: NextPage = () => {
); );
} }
if (poll) {
return <PollPage poll={poll} />;
}
if (didError) { if (didError) {
return <Custom404 />; return <Custom404 />;
} }
return !poll ? ( return <FullPageLoader>{t("loading")}</FullPageLoader>;
<FullPageLoader>{t("loading")}</FullPageLoader>
) : (
<PollPage poll={poll} />
);
}; };
export const getServerSideProps: GetServerSideProps = async ({ export const getServerSideProps: GetServerSideProps = withSessionSsr(
locale = "en", async ({ locale = "en", req }) => {
}) => { try {
try { return {
return { props: {
props: { ...(await serverSideTranslations(locale, ["app"])),
...(await serverSideTranslations(locale, ["app"])), user: req.session.user ?? null,
}, },
}; };
} catch { } catch {
return { notFound: true }; 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 DATABASE_URL=postgres://your-database/db
# support email - used as FROM email by SMTP server
SUPPORT_EMAIL=foo@yourdomain.com SUPPORT_EMAIL=foo@yourdomain.com
# SMTP server - required if you want to send emails
SMTP_HOST=your-smtp-server SMTP_HOST=your-smtp-server
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE="false" SMTP_SECURE=false
SMTP_USER=your-smtp-user SMTP_USER=your-smtp-user
SMTP_PWD=your-smtp-password SMTP_PWD=your-smtp-password

View file

@ -1,6 +1,6 @@
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
generator client { generator client {
@ -8,14 +8,14 @@ generator client {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
email String @unique() @db.Citext email String @unique() @db.Citext
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
polls Poll[] polls Poll[]
participants Participant[] participants Participant[]
comments Comment[] comments Comment[]
} }
enum PollType { enum PollType {
@ -34,7 +34,6 @@ model Poll {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
votes Vote[] votes Vote[]
verificationCode String
timeZone String? timeZone String?
verified Boolean @default(false) verified Boolean @default(false)
options Option[] options Option[]
@ -46,8 +45,6 @@ model Poll {
legacy Boolean @default(false) legacy Boolean @default(false)
closed Boolean @default(false) closed Boolean @default(false)
notifications Boolean @default(false) notifications Boolean @default(false)
@@unique([urlId, verificationCode])
} }
enum Role { enum Role {
@ -56,60 +53,63 @@ enum Role {
} }
model Link { model Link {
urlId String @id @unique urlId String @id @unique
role Role role Role
pollId String pollId String
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([pollId, role]) @@unique([pollId, role])
} }
model Participant { model Participant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) guestId String?
pollId String poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
votes Vote[] pollId String
createdAt DateTime @default(now()) votes Vote[]
updatedAt DateTime? @updatedAt createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@unique([id, pollId]) @@unique([id, pollId])
} }
model Option { model Option {
id String @id @default(cuid()) id String @id @default(cuid())
value String value String
pollId String pollId String
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
votes Vote[] votes Vote[]
} }
model Vote { model Vote {
id String @id @default(cuid()) id String @id @default(cuid())
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
participantId String participantId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade) option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionId String optionId String
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String pollId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
} }
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
content String content String
poll Poll @relation(fields:[pollId], references: [urlId], onDelete: Cascade) poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String pollId String
authorName String authorName String
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?
createdAt DateTime @default(now()) guestId String?
updatedAt DateTime? @updatedAt createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@unique([id, pollId]) @@unique([id, pollId])
} }

View file

@ -7,6 +7,7 @@
body, body,
#__next { #__next {
height: 100%; height: 100%;
@apply bg-slate-50;
} }
body { body {
@apply bg-slate-50 text-base text-slate-600; @apply bg-slate-50 text-base text-slate-600;
@ -37,7 +38,7 @@
@apply mb-1 block text-sm text-slate-800; @apply mb-1 block text-sm text-slate-800;
} }
button { button {
@apply focus:outline-none focus:ring-indigo-600; @apply cursor-default focus:outline-none focus:ring-indigo-600;
} }
#floating-ui-root { #floating-ui-root {
@ -59,23 +60,23 @@
@apply input px-3 py-3; @apply input px-3 py-3;
} }
.input-error { .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 { .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 { .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 { a.btn {
@apply hover:no-underline; @apply hover:no-underline;
} }
.btn-default { .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 { .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 { .btn-link {
@apply inline-flex items-center text-indigo-500 underline; @apply inline-flex items-center text-indigo-500 underline;
@ -87,7 +88,12 @@
@apply pointer-events-none; @apply pointer-events-none;
} }
.btn-primary { .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 { a.btn-primary {
@apply text-white; @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> <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;"> <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 %>>"> <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> </a>
</td> </td>
</tr> </tr>
@ -132,6 +132,10 @@
<p style="cursor: default;"> <p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a> <a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull; &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> <a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p> </p>
</td> </td>

View file

@ -83,7 +83,7 @@
<tr> <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;"> <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 %>"> <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> </a>
</td> </td>
</tr> </tr>
@ -132,6 +132,10 @@
<p style="cursor: default;"> <p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a> <a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull; &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> <a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p> </p>
</td> </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> <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;"> <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 %>"> <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> </a>
</td> </td>
</tr> </tr>
@ -105,11 +105,6 @@
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]--> </span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;">&nbsp;</i><![endif]-->
</a> </a>
</div> </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;"> <p style="margin-bottom: 8px;">
In case you lose it, here's a link to your poll for the In case you lose it, here's a link to your poll for the
future 😉 future 😉
@ -144,6 +139,10 @@
<p style="cursor: default;"> <p style="cursor: default;">
<a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a> <a href="<%= it.homePageUrl %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Website</a>
&bull; &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> <a href="mailto:<%= it.supportEmail %>" class="hover-underline" style="color: #6366f1; text-decoration: none;">Contact</a>
</p> </p>
</td> </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("[data-testid='specify-times-switch']");
await page.click("text='12:00 PM'"); await page.click("text='12:00 PM'");
await page.click("text='1: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 expect(page.locator('text="Are you sure?"')).toBeVisible();
await page.click("text='Delete'"); 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 page.click("text=Save");
await expect(page.locator("text='Test user'")).toBeVisible(); 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("text=Edit");
await page.click("data-testid=poll-option >> nth=1"); 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 expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible();
await page.click("text='New Participant'");
await page.type('[placeholder="Your name"]', "Test user"); 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 // 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. // 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.locator('[name="votes"] >> nth=3').click();
await page.click('[data-testid="submitNewParticipant"]'); await page.click('[data-testid="submitNewParticipant"]');
await expect(page.locator("text='Test user'")).toBeVisible(); 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!"); const comment = page.locator("data-testid=comment");
await page.click("text='Send'"); await expect(comment.locator("text='This is a comment!'")).toBeVisible();
await expect(comment.locator("text=You")).toBeVisible();
await expect(page.locator("text='This is a comment!'")).toBeVisible();
}); });

View file

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

View file

@ -5,47 +5,128 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import path from "path"; import path from "path";
import { prisma } from "../db"; import { prisma } from "../db";
import absoluteUrl from "./absolute-url";
import { sendEmail } from "./send-email"; 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) => { export const getQueryParam = (req: NextApiRequest, queryKey: string) => {
const value = req.query[queryKey]; const value = req.query[queryKey];
return typeof value === "string" ? value : value[0]; return typeof value === "string" ? value : value[0];
}; };
export const withLink = ( type ApiMiddleware<T, P extends Record<string, unknown>> = (
handler: ( ctx: {
req: NextApiRequest, req: NextApiRequest;
res: NextApiResponse, res: NextApiResponse<T>;
link: Link, } & P,
) => Promise<void>, ) => 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 => { ): NextApiHandler => {
return async (req, res) => { return async (req, res) => {
const urlId = getQueryParam(req, "urlId"); const urlId = getQueryParam(req, "urlId");
const link = await prisma.link.findUnique({ where: { urlId } }); const link = await prisma.link.findUnique({ where: { urlId } });
if (!link) { if (!link) {
const message = `Could not find link with urlId: ${urlId}`; res.status(404).json({
return res.status(404).json({
status: 404, 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[]) => export const getAdminLink = (links: Link[]) =>
links.find((link) => link.role === "admin"); 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) => { import { prisma } from "../db";
return await jwtVerify(jwt, new TextEncoder().encode(process.env.JWT_SECRET)); 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 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", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
12, 12,
); );
export const randomid = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyz",
12,
);

377
yarn.lock
View file

@ -1150,14 +1150,13 @@
dependencies: dependencies:
"@floating-ui/core" "^0.6.2" "@floating-ui/core" "^0.6.2"
"@floating-ui/react-dom-interactions@^0.3.1": "@floating-ui/react-dom-interactions@^0.4.0":
version "0.3.1" version "0.4.0"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.3.1.tgz#abc0cb4b18e6f095397e50f9846572eee4e34554" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.4.0.tgz#b4d951aaa3b0a66cd0b2787a7bf9d5d7b2f12021"
integrity sha512-tP2KEh7EHJr5hokSBHcPGojb+AorDNUf0NYfZGg/M+FsMvCOOsSEeEF0O1NDfETIzDnpbHnCs0DuvCFhSMSStg== integrity sha512-pcXxg2QVrQmlo54v39fIfPNda3bkFibuQVji0b4I9PLXOTV+KI5phc8ANnKLdfttfsYap/0bAknS9dQW97KShw==
dependencies: dependencies:
"@floating-ui/react-dom" "^0.6.3" "@floating-ui/react-dom" "^0.6.3"
aria-hidden "^1.1.3" aria-hidden "^1.1.3"
point-in-polygon "^1.1.0"
use-isomorphic-layout-effect "^1.1.1" use-isomorphic-layout-effect "^1.1.1"
"@floating-ui/react-dom@^0.6.3": "@floating-ui/react-dom@^0.6.3":
@ -1168,11 +1167,48 @@
"@floating-ui/dom" "^0.4.5" "@floating-ui/dom" "^0.4.5"
use-isomorphic-layout-effect "^1.1.1" 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" version "9.2.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
integrity sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw== 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": "@hapi/topo@^5.0.0":
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" 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" resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q== integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
"@prisma/client@^3.12.0": "@prisma/client@^3.13.0":
version "3.12.0" version "3.13.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.13.0.tgz#84511ebdf6ba75f77ca08495b9f73f22c4255654"
integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw== integrity sha512-lnEA2tTyVbO5mS1ehmHJQKBDiKB8shaR6s3azwj3Azfi5XHIfnqmkolLCvUeFYnkDCNVzGXJpUgKwQt/UOOYVQ==
dependencies: 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": "@prisma/engines-version@3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" version "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#676aca309d66d9be2aad8911ca31f1ee5561041c"
integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw== integrity sha512-TGp9rvgJIKo8NgvAHSwOosbut9mTA7VC6/rpQI9gh+ySSRjdQFhbGyNUiOcQrlI9Ob2DWeO7y4HEnhdKxYiECg==
"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980": "@prisma/engines@3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" version "3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.13.0-17.efdf9b1183dddfd4258cd181a72125755215ab7b.tgz#d3a457cec4ef7a3b3412c45b1f2eac68c974474b"
integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ== integrity sha512-Ip9CcCeUocH61eXu4BUGpvl5KleQyhcUVLpWCv+0ZmDv44bFaDpREqjGHHdRupvPN/ugB6gTlD9b9ewdj02yVA==
"@restart/hooks@^0.3.25": "@restart/hooks@^0.3.25":
version "0.3.26" version "0.3.26"
@ -1665,6 +1701,45 @@
resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz" resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== 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": "@types/hoist-non-react-statics@^3.3.1":
version "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" 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" resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz"
integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
"@types/json-schema@^7.0.3": "@types/json-schema@^7.0.9":
version "7.0.7" version "7.0.11"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/json5@^0.0.29": "@types/json5@^0.0.29":
version "0.0.29" version "0.0.29"
@ -1712,6 +1787,11 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz"
integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== 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": "@types/mixpanel-browser@^2.38.0":
version "2.38.0" version "2.38.0"
resolved "https://registry.yarnpkg.com/@types/mixpanel-browser/-/mixpanel-browser-2.38.0.tgz#b3e28e1ba06c10a9f88510b88f1ac9d1b2adfc42" 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" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644"
integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== 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": "@types/nodemailer@^6.4.4":
version "6.4.4" version "6.4.4"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" 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" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== 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": "@types/react-big-calendar@^0.31.0":
version "0.31.0" version "0.31.0"
resolved "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-0.31.0.tgz" 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" resolved "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz"
integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== 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": "@types/smoothscroll-polyfill@^0.3.1":
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.1.tgz#77fb3a6e116bdab4a5959122e3b8e201224dcd49" resolved "https://registry.yarnpkg.com/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.1.tgz#77fb3a6e116bdab4a5959122e3b8e201224dcd49"
@ -1822,41 +1925,20 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@typescript-eslint/eslint-plugin@^4.23.0": "@typescript-eslint/eslint-plugin@^5.21.0":
version "4.23.0" version "5.21.0"
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.23.0.tgz" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.21.0.tgz#bfc22e0191e6404ab1192973b3b4ea0461c1e878"
integrity sha512-tGK1y3KIvdsQEEgq6xNn1DjiFJtl+wn8JJQiETtCbdQxw1vzjXyAaIkEmO2l6Nq24iy3uZBMFQjZ6ECf1QdgGw== integrity sha512-fTU85q8v5ZLpoZEyn/u1S2qrFOhi33Edo2CZ0+q1gDaWWm0JuPh3bgOyU8lM0edIEYgKLDkPFiZX2MOupgjlyg==
dependencies: dependencies:
"@typescript-eslint/experimental-utils" "4.23.0" "@typescript-eslint/scope-manager" "5.21.0"
"@typescript-eslint/scope-manager" "4.23.0" "@typescript-eslint/type-utils" "5.21.0"
debug "^4.1.1" "@typescript-eslint/utils" "5.21.0"
debug "^4.3.2"
functional-red-black-tree "^1.0.1" functional-red-black-tree "^1.0.1"
lodash "^4.17.15" ignore "^5.1.8"
regexpp "^3.0.0" regexpp "^3.2.0"
semver "^7.3.2" semver "^7.3.5"
tsutils "^3.17.1" tsutils "^3.21.0"
"@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"
"@typescript-eslint/parser@^5.0.0": "@typescript-eslint/parser@^5.0.0":
version "5.16.0" version "5.16.0"
@ -1868,13 +1950,15 @@
"@typescript-eslint/typescript-estree" "5.16.0" "@typescript-eslint/typescript-estree" "5.16.0"
debug "^4.3.2" debug "^4.3.2"
"@typescript-eslint/scope-manager@4.23.0": "@typescript-eslint/parser@^5.21.0":
version "4.23.0" version "5.21.0"
resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.23.0.tgz" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.21.0.tgz#6cb72673dbf3e1905b9c432175a3c86cdaf2071f"
integrity sha512-ZZ21PCFxPhI3n0wuqEJK9omkw51wi2bmeKJvlRZPH5YFkcawKOuRMQMnI8mH6Vo0/DoHSeZJnHiIx84LmVQY+w== integrity sha512-8RUwTO77hstXUr3pZoWZbRQUxXcSXafZ8/5gpnQCfXvgmP9gpNlRGlWzvfbEQ14TLjmtU8eGnONkff8U2ui2Eg==
dependencies: dependencies:
"@typescript-eslint/types" "4.23.0" "@typescript-eslint/scope-manager" "5.21.0"
"@typescript-eslint/visitor-keys" "4.23.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": "@typescript-eslint/scope-manager@5.16.0":
version "5.16.0" version "5.16.0"
@ -1884,28 +1968,32 @@
"@typescript-eslint/types" "5.16.0" "@typescript-eslint/types" "5.16.0"
"@typescript-eslint/visitor-keys" "5.16.0" "@typescript-eslint/visitor-keys" "5.16.0"
"@typescript-eslint/types@4.23.0": "@typescript-eslint/scope-manager@5.21.0":
version "4.23.0" version "5.21.0"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.23.0.tgz" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.21.0.tgz#a4b7ed1618f09f95e3d17d1c0ff7a341dac7862e"
integrity sha512-oqkNWyG2SLS7uTWLZf6Sr7Dm02gA5yxiz1RP87tvsmDsguVATdpVguHr4HoGOcFOpCvx9vtCSCyQUGfzq28YCw== 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": "@typescript-eslint/types@5.16.0":
version "5.16.0" version "5.16.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee"
integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g== integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g==
"@typescript-eslint/typescript-estree@4.23.0": "@typescript-eslint/types@5.21.0":
version "4.23.0" version "5.21.0"
resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.23.0.tgz" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.21.0.tgz#8cdb9253c0dfce3f2ab655b9d36c03f72e684017"
integrity sha512-5Sty6zPEVZF5fbvrZczfmLCOcby3sfrSPu30qKoY1U3mca5/jvU5cwsPb/CO6Q3ByRjixTMIVsDkqwIxCf/dMw== integrity sha512-XnOOo5Wc2cBlq8Lh5WNvAgHzpjnEzxn4CJBwGkcau7b/tZ556qrWXQz4DJyChYg8JZAD06kczrdgFPpEQZfDsA==
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/typescript-estree@5.16.0": "@typescript-eslint/typescript-estree@5.16.0":
version "5.16.0" version "5.16.0"
@ -1920,13 +2008,30 @@
semver "^7.3.5" semver "^7.3.5"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/visitor-keys@4.23.0": "@typescript-eslint/typescript-estree@5.21.0":
version "4.23.0" version "5.21.0"
resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.23.0.tgz" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.21.0.tgz#9f0c233e28be2540eaed3df050f0d54fb5aa52de"
integrity sha512-5PNe5cmX9pSifit0H+nPoQBXdbNzi5tOEec+3riK+ku4e3er37pKxMKDH5Ct5Y4fhWxcD4spnlYjxi9vXbSpwg== integrity sha512-Y8Y2T2FNvm08qlcoSMoNchh9y2Uj3QmjtwNMdRQkcFG7Muz//wfJBGBxh8R7HAGQFpgYpdHqUpEoPQk+q9Kjfg==
dependencies: dependencies:
"@typescript-eslint/types" "4.23.0" "@typescript-eslint/types" "5.21.0"
eslint-visitor-keys "^2.0.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": "@typescript-eslint/visitor-keys@5.16.0":
version "5.16.0" version "5.16.0"
@ -1936,6 +2041,14 @@
"@typescript-eslint/types" "5.16.0" "@typescript-eslint/types" "5.16.0"
eslint-visitor-keys "^3.0.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": "@xobotyi/scrollbar-width@^1.9.5":
version "1.9.5" version "1.9.5"
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz" 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" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== 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: copy-to-clipboard@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz" 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" 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== 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" version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
@ -3066,13 +3184,20 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.1:
esrecurse "^4.3.0" esrecurse "^4.3.0"
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-utils@^2.0.0, eslint-utils@^2.1.0: eslint-utils@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz"
integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
dependencies: dependencies:
eslint-visitor-keys "^1.1.0" 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: eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz" 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" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== 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: fast-glob@^3.2.11, fast-glob@^3.2.9:
version "3.2.11" version "3.2.11"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz" 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" resolved "https://registry.yarnpkg.com/github-buttons/-/github-buttons-2.21.1.tgz#9e55eb83b70c9149a21c235db2e971c53d4d98a2"
integrity sha512-n9bCQ8sj+5oX1YH5NeyWGbAclRDtHEhMBzqw2ctsWpdEHOwVgfruRu0VIVy01Ah10dd/iFajMHYU71L7IBWBOw== 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" version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@ -3463,18 +3576,6 @@ globals@^13.6.0:
dependencies: dependencies:
type-fest "^0.20.2" 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: globby@^11.0.4:
version "11.1.0" version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" 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" resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
ignore@^5.1.4: ignore@^5.1.8, ignore@^5.2.0:
version "5.1.8"
resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
ignore@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== 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" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= 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: is-arrayish@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" 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" resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz"
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= 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" version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 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" resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.2, micromatch@^4.0.4: micromatch@^4.0.4:
version "4.0.4" version "4.0.4"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz" resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz"
integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== 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" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg== 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: popmotion@11.0.3:
version "11.0.3" version "11.0.3"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" 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" ansi-styles "^5.0.0"
react-is "^17.0.1" react-is "^17.0.1"
prisma@^3.12.0: prisma@^3.13.0:
version "3.12.0" version "3.13.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.13.0.tgz#b11edd5631222ff1bf1d5324732d47801386aa8c"
integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg== integrity sha512-oO1auBnBtieGdiN+57IgsA9Vr7Sy4HkILi1KSaUG4mpKfEbnkTGnLOxAqjLed+K2nsG/GtE1tJBtB7JxN1a78Q==
dependencies: 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: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"
@ -5111,11 +5214,16 @@ regexp.prototype.flags@^1.4.1:
call-bind "^1.0.2" call-bind "^1.0.2"
define-properties "^1.1.3" define-properties "^1.1.3"
regexpp@^3.0.0, regexpp@^3.1.0: regexpp@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz" resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz"
integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== 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: regexpu-core@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz" 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" resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 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" version "7.3.5"
resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz" resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== 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" resolved "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz"
integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== 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: tsconfig-paths@^3.12.0, tsconfig-paths@^3.14.1:
version "3.14.1" version "3.14.1"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" 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" resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tsutils@^3.17.1, tsutils@^3.21.0: tsutils@^3.21.0:
version "3.21.0" version "3.21.0"
resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz"
integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==