mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-24 06:16:25 +02:00
Sessions (#162)
This commit is contained in:
parent
1d7bcddf1b
commit
5c991d7011
83 changed files with 2463 additions and 1178 deletions
|
@ -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",
|
||||||
|
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -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
|
||||||
|
|
44
README.md
44
README.md
|
@ -2,7 +2,6 @@
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E)
|
[](https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E)
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
26
components/badge.tsx
Normal 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;
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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">•</span>
|
<span className="mr-1 text-slate-400">•</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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
|
|
@ -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) => {
|
||||||
|
|
3
components/icons/login.svg
Normal file
3
components/icons/login.svg
Normal 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 |
3
components/icons/logout.svg
Normal file
3
components/icons/logout.svg
Normal 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 |
3
components/icons/question-mark-circle.svg
Normal file
3
components/icons/question-mark-circle.svg
Normal 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 |
|
@ -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 |
73
components/login-form.tsx
Normal file
73
components/login-form.tsx
Normal 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'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;
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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");
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
103
components/session.tsx
Normal 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;
|
||||||
|
};
|
|
@ -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">
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
|
63
components/user-profile/guest-session.tsx
Normal file
63
components/user-profile/guest-session.tsx
Normal 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'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;
|
|
@ -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;
|
6
i18next.d.ts → declarations/i18next.d.ts
vendored
6
i18next.d.ts → declarations/i18next.d.ts
vendored
|
@ -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
17
declarations/iron-session.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
17
migrations/20220506105524_sessions_update/migration.sql
Normal file
17
migrations/20220506105524_sessions_update/migration.sql
Normal 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";
|
12
package.json
12
package.json
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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
35
pages/api/login.ts
Normal 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
6
pages/api/logout.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { withSessionRoute } from "utils/auth";
|
||||||
|
|
||||||
|
export default withSessionRoute((req, res) => {
|
||||||
|
req.session.destroy();
|
||||||
|
res.send({ ok: true });
|
||||||
|
});
|
|
@ -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" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 });
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
@ -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" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
20
pages/api/user.ts
Normal 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
112
pages/login.tsx
Normal 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;
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
102
schema.prisma
102
schema.prisma
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
20
style.css
20
style.css
|
@ -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
148
templates/login.html
Normal 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.͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||||
|
 ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||||
|
 ͏ ͏ ͏ ͏ ͏
|
||||||
|
</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;"> </i><![endif]-->
|
||||||
|
<span style="mso-text-raise: 16px">Log me in →
|
||||||
|
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </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;">
|
||||||
|
‌
|
||||||
|
</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>
|
||||||
|
•
|
||||||
|
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||||
|
•
|
||||||
|
<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>
|
|
@ -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>
|
||||||
•
|
•
|
||||||
|
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||||
|
•
|
||||||
<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>
|
||||||
|
|
|
@ -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>
|
||||||
•
|
•
|
||||||
|
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||||
|
•
|
||||||
<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>
|
||||||
|
|
149
templates/new-poll-verified.html
Normal file
149
templates/new-poll-verified.html
Normal 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!͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||||
|
 ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||||
|
 ͏ ͏ ͏ ͏ ͏
|
||||||
|
</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;"> </i><![endif]-->
|
||||||
|
<span style="mso-text-raise: 16px">Got to poll →
|
||||||
|
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </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;">
|
||||||
|
‌
|
||||||
|
</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>
|
||||||
|
•
|
||||||
|
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||||
|
•
|
||||||
|
<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>
|
|
@ -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%;"> </i><![endif]-->
|
</span> <!--[if mso]><i style="letter-spacing: 27px; mso-font-width: -100%;"> </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>
|
||||||
•
|
•
|
||||||
|
<a href="https://twitter.com/ralllyco" class="hover-underline" style="color: #6366f1; text-decoration: none;">Twitter</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/lukevella/rallly" class="hover-underline" style="color: #6366f1; text-decoration: none;">Github</a>
|
||||||
|
•
|
||||||
<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>
|
||||||
|
|
|
@ -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'");
|
||||||
});
|
});
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 };
|
|
||||||
};
|
|
|
@ -4,3 +4,8 @@ export const nanoid = customAlphabet(
|
||||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||||||
12,
|
12,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const randomid = customAlphabet(
|
||||||
|
"0123456789abcdefghijklmnopqrstuvwxyz",
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
|
377
yarn.lock
377
yarn.lock
|
@ -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==
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue