mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-13 17:06:48 +02:00
Updated links model and poll page (#206)
* Improved sharing * Updated desktop poll
This commit is contained in:
parent
c4cbf2f6bb
commit
2ead375b42
50 changed files with 955 additions and 1848 deletions
|
@ -30,12 +30,11 @@
|
|||
"date-fns": "^2.28.0",
|
||||
"date-fns-tz": "^1.2.2",
|
||||
"eta": "^1.12.3",
|
||||
"framer-motion": "^6.2.9",
|
||||
"framer-motion": "^6.3.11",
|
||||
"iron-session": "^6.1.3",
|
||||
"jose": "^4.5.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mongodb": "^4.5.0",
|
||||
"nanoid": "^3.1.30",
|
||||
"next": "^12.1.4",
|
||||
"next-i18next": "^10.5.0",
|
||||
|
@ -60,7 +59,7 @@
|
|||
"zod": "^3.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.20.1",
|
||||
"@playwright/test": "^1.22.2",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/react": "^17.0.5",
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "polls"
|
||||
ADD COLUMN "admin_url_id" TEXT,
|
||||
ADD COLUMN "participant_url_id" TEXT;
|
||||
|
||||
UPDATE polls
|
||||
SET participant_url_id=(SELECT url_id FROM links WHERE polls.url_id=links.poll_id AND links."role"='participant');
|
||||
|
||||
UPDATE polls
|
||||
SET admin_url_id=(SELECT url_id FROM links WHERE polls.url_id=links.poll_id AND links."role"='admin');
|
||||
|
||||
ALTER TABLE "polls"
|
||||
ALTER COLUMN "admin_url_id" SET NOT NULL,
|
||||
ALTER COLUMN "participant_url_id" SET NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "links";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "role";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "polls_participant_url_id_key" ON "polls"("participant_url_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "polls_admin_url_id_key" ON "polls"("admin_url_id");
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `polls` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `url_id` on the `polls` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[id]` on the table `polls` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `id` to the `polls` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "Poll_urlId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "polls_url_id_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "polls"
|
||||
RENAME COLUMN "url_id" TO "id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "polls_id_key" ON "polls"("id");
|
|
@ -1,11 +1,11 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
referentialIntegrity = "prisma"
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["referentialIntegrity"]
|
||||
}
|
||||
|
||||
|
@ -29,60 +29,43 @@ enum PollType {
|
|||
}
|
||||
|
||||
model Poll {
|
||||
urlId String @id @unique @map("url_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deadline DateTime?
|
||||
title String
|
||||
type PollType
|
||||
description String?
|
||||
location String?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @map("user_id")
|
||||
votes Vote[]
|
||||
timeZone String? @map("time_zone")
|
||||
verified Boolean @default(false)
|
||||
options Option[]
|
||||
participants Participant[]
|
||||
authorName String @default("") @map("author_name")
|
||||
demo Boolean @default(false)
|
||||
comments Comment[]
|
||||
links Link[]
|
||||
legacy Boolean @default(false)
|
||||
closed Boolean @default(false)
|
||||
notifications Boolean @default(false)
|
||||
deleted Boolean @default(false)
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
touchedAt DateTime @default(now()) @map("touched_at")
|
||||
id String @id @unique @map("id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deadline DateTime?
|
||||
title String
|
||||
type PollType
|
||||
description String?
|
||||
location String?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @map("user_id")
|
||||
votes Vote[]
|
||||
timeZone String? @map("time_zone")
|
||||
verified Boolean @default(false)
|
||||
options Option[]
|
||||
participants Participant[]
|
||||
authorName String @default("") @map("author_name")
|
||||
demo Boolean @default(false)
|
||||
comments Comment[]
|
||||
legacy Boolean @default(false)
|
||||
closed Boolean @default(false)
|
||||
notifications Boolean @default(false)
|
||||
deleted Boolean @default(false)
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
touchedAt DateTime @default(now()) @map("touched_at")
|
||||
participantUrlId String @unique @map("participant_url_id")
|
||||
adminUrlId String @unique @map("admin_url_id")
|
||||
|
||||
@@map("polls")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
admin
|
||||
participant
|
||||
|
||||
@@map("role")
|
||||
}
|
||||
|
||||
model Link {
|
||||
urlId String @id @unique @map("url_id")
|
||||
role Role
|
||||
pollId String @map("poll_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([pollId, role])
|
||||
@@map("links")
|
||||
}
|
||||
|
||||
model Participant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String? @map("user_id")
|
||||
guestId String? @map("guest_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
||||
poll Poll @relation(fields: [pollId], references: [id])
|
||||
pollId String @map("poll_id")
|
||||
votes Vote[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -96,7 +79,7 @@ model Option {
|
|||
id String @id @default(cuid())
|
||||
value String
|
||||
pollId String @map("poll_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
||||
poll Poll @relation(fields: [pollId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
votes Vote[]
|
||||
|
@ -118,7 +101,7 @@ model Vote {
|
|||
participantId String @map("participant_id")
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionId String @map("option_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
||||
poll Poll @relation(fields: [pollId], references: [id])
|
||||
pollId String @map("poll_id")
|
||||
type VoteType @default(yes)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -130,7 +113,7 @@ model Vote {
|
|||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
content String
|
||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
||||
poll Poll @relation(fields: [pollId], references: [id])
|
||||
pollId String @map("poll_id")
|
||||
authorName String @map("author_name")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
"calendarHelp": "You can't create a poll without any options. Add at least one option to continue.",
|
||||
"errorCreate": "Uh oh! There was a problem creating your poll. The error has been logged and we'll try to fix it.",
|
||||
"share": "Share",
|
||||
"shareDescription": "This poll is open to anyone who has the following link:",
|
||||
"shareDescription": "Give this link to your <b>participants</b> to allow them to vote on your poll.",
|
||||
"requiredNameError": "Name is required",
|
||||
"remove": "Remove",
|
||||
"change": "Change",
|
||||
|
@ -38,9 +38,7 @@
|
|||
"loading": "Loading…",
|
||||
"loadingParticipants": "Loading participants…",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Full access to edit this poll.",
|
||||
"participant": "Participant",
|
||||
"participantDescription": "Partial access to vote and comment on this poll.",
|
||||
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
|
||||
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
|
||||
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"getStarted": "Get started",
|
||||
"viewDemo": "View demo",
|
||||
"viewDemo": "Live demo",
|
||||
"footerCredit": "Self-funded and built by <a>@imlukevella</a>"
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ const Badge: React.VoidFunctionComponent<{
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"inline-flex h-5 cursor-default items-center rounded-md px-1 text-xs",
|
||||
"inline-flex h-5 cursor-default items-center rounded-md px-1 text-xs lg:text-sm",
|
||||
{
|
||||
"bg-slate-200 text-slate-500": color === "gray",
|
||||
"bg-amber-100 text-amber-500": color === "amber",
|
||||
|
|
|
@ -95,7 +95,7 @@ const Page: NextPage<CreatePollPageProps> = ({
|
|||
const plausible = usePlausible();
|
||||
|
||||
const createPoll = trpc.useMutation(["polls.create"], {
|
||||
onSuccess: (poll) => {
|
||||
onSuccess: (res) => {
|
||||
setIsRedirecting(true);
|
||||
plausible("Created poll", {
|
||||
props: {
|
||||
|
@ -104,7 +104,7 @@ const Page: NextPage<CreatePollPageProps> = ({
|
|||
},
|
||||
});
|
||||
setPersistedFormData(initialNewEventData);
|
||||
router.replace(`/admin/${poll.urlId}`);
|
||||
router.replace(`/admin/${res.urlId}?sharing=true`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -27,9 +27,9 @@ interface CommentForm {
|
|||
const Discussion: React.VoidFunctionComponent = () => {
|
||||
const { locale } = usePreferences();
|
||||
const queryClient = trpc.useContext();
|
||||
const {
|
||||
poll: { pollId },
|
||||
} = usePoll();
|
||||
const { poll } = usePoll();
|
||||
|
||||
const pollId = poll.id;
|
||||
|
||||
const { data: comments } = trpc.useQuery(
|
||||
["polls.comments.list", { pollId }],
|
||||
|
@ -53,8 +53,6 @@ const Discussion: React.VoidFunctionComponent = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const { poll } = usePoll();
|
||||
|
||||
const deleteComment = trpc.useMutation("polls.comments.delete", {
|
||||
onMutate: ({ commentId }) => {
|
||||
queryClient.setQueryData(
|
||||
|
@ -96,9 +94,7 @@ const Discussion: React.VoidFunctionComponent = () => {
|
|||
<AnimatePresence initial={false}>
|
||||
{comments.map((comment) => {
|
||||
const canDelete =
|
||||
poll.role === "admin" ||
|
||||
session.ownsObject(comment) ||
|
||||
isUnclaimed(comment);
|
||||
poll.admin || session.ownsObject(comment) || isUnclaimed(comment);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
@ -135,23 +131,22 @@ const Discussion: React.VoidFunctionComponent = () => {
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
{canDelete ? (
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
trigger={<CompactButton icon={DotsHorizontal} />}
|
||||
>
|
||||
<DropdownItem
|
||||
icon={Trash}
|
||||
label="Delete comment"
|
||||
onClick={() => {
|
||||
deleteComment.mutate({
|
||||
commentId: comment.id,
|
||||
pollId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
trigger={<CompactButton icon={DotsHorizontal} />}
|
||||
>
|
||||
<DropdownItem
|
||||
icon={Trash}
|
||||
label="Delete comment"
|
||||
disabled={!canDelete}
|
||||
onClick={() => {
|
||||
deleteComment.mutate({
|
||||
commentId: comment.id,
|
||||
pollId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="w-fit whitespace-pre-wrap">
|
||||
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
|
||||
|
@ -187,11 +182,7 @@ const Discussion: React.VoidFunctionComponent = () => {
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
loading={formState.isSubmitting}
|
||||
type="primary"
|
||||
>
|
||||
<Button htmlType="submit" loading={formState.isSubmitting}>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -87,6 +87,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
|||
color={participant.color}
|
||||
sidebarWidth={sidebarWidth}
|
||||
columnWidth={columnWidth}
|
||||
participantId={`participant${i}`}
|
||||
name={participant.name}
|
||||
votes={options.map((_, i) => {
|
||||
return participant.votes.some((vote) => vote === i) ? "yes" : "no";
|
||||
|
|
3
src/components/icons/key.svg
Normal file
3
src/components/icons/key.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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
After Width: | Height: | Size: 319 B |
|
@ -20,7 +20,10 @@ import { useRequiredContext } from "./use-required-context";
|
|||
type PollContextValue = {
|
||||
userAlreadyVoted: boolean;
|
||||
poll: GetPollApiResponse;
|
||||
urlId: string;
|
||||
admin: boolean;
|
||||
targetTimeZone: string;
|
||||
participantUrl: string;
|
||||
setTargetTimeZone: (timeZone: string) => void;
|
||||
pollType: "date" | "timeSlot";
|
||||
highScore: number;
|
||||
|
@ -49,9 +52,11 @@ export const usePoll = () => {
|
|||
};
|
||||
|
||||
export const PollContextProvider: React.VoidFunctionComponent<{
|
||||
value: GetPollApiResponse;
|
||||
poll: GetPollApiResponse;
|
||||
urlId: string;
|
||||
admin: boolean;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ value: poll, children }) => {
|
||||
}> = ({ poll, urlId, admin, children }) => {
|
||||
const { participants } = useParticipants();
|
||||
const [isDeleted, setDeleted] = React.useState(false);
|
||||
const { user } = useSession();
|
||||
|
@ -129,10 +134,17 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
);
|
||||
});
|
||||
|
||||
const { participantUrlId } = poll;
|
||||
|
||||
const participantUrl = `${window.location.origin}/p/${participantUrlId}`;
|
||||
|
||||
return {
|
||||
optionIds,
|
||||
userAlreadyVoted,
|
||||
poll,
|
||||
urlId,
|
||||
admin,
|
||||
participantUrl,
|
||||
getParticipantById: (participantId) => {
|
||||
return participantById[participantId];
|
||||
},
|
||||
|
@ -152,7 +164,17 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
isDeleted,
|
||||
setDeleted,
|
||||
};
|
||||
}, [getScore, isDeleted, locale, participants, poll, targetTimeZone, user]);
|
||||
}, [
|
||||
admin,
|
||||
getScore,
|
||||
isDeleted,
|
||||
locale,
|
||||
participants,
|
||||
poll,
|
||||
targetTimeZone,
|
||||
urlId,
|
||||
user,
|
||||
]);
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMount } from "react-use";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import LocationMarker from "@/components/icons/location-marker.svg";
|
||||
import LockClosed from "@/components/icons/lock-closed.svg";
|
||||
import Share from "@/components/icons/share.svg";
|
||||
import { preventWidows } from "@/utils/prevent-widows";
|
||||
|
@ -19,11 +20,11 @@ import { useUpdatePollMutation } from "./poll/mutations";
|
|||
import NotificationsToggle from "./poll/notifications-toggle";
|
||||
import PollSubheader from "./poll/poll-subheader";
|
||||
import TruncatedLinkify from "./poll/truncated-linkify";
|
||||
import { UnverifiedPollNotice } from "./poll/unverified-poll-notice";
|
||||
import { useTouchBeacon } from "./poll/use-touch-beacon";
|
||||
import { UserAvatarProvider } from "./poll/user-avatar";
|
||||
import VoteIcon from "./poll/vote-icon";
|
||||
import { usePoll } from "./poll-context";
|
||||
import Popover from "./popover";
|
||||
import { useSession } from "./session";
|
||||
import Sharing from "./sharing";
|
||||
import StandardLayout from "./standard-layout";
|
||||
|
@ -34,19 +35,13 @@ const DesktopPoll = React.lazy(() => import("@/components/poll/desktop-poll"));
|
|||
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
||||
|
||||
const PollPage: NextPage = () => {
|
||||
const { poll } = usePoll();
|
||||
const { poll, urlId, admin } = usePoll();
|
||||
const { participants } = useParticipants();
|
||||
const router = useRouter();
|
||||
|
||||
useTouchBeacon(poll.pollId);
|
||||
useTouchBeacon(poll.id);
|
||||
|
||||
useMount(() => {
|
||||
const path = poll.role === "admin" ? "admin" : "p";
|
||||
|
||||
if (!new RegExp(`^/${path}`).test(router.asPath)) {
|
||||
router.replace(`/${path}/${poll.urlId}`, undefined, { shallow: true });
|
||||
}
|
||||
});
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const session = useSession();
|
||||
|
||||
|
@ -58,7 +53,7 @@ const PollPage: NextPage = () => {
|
|||
const verifyEmail = trpc.useMutation(["polls.verification.verify"], {
|
||||
onSuccess: () => {
|
||||
toast.success("Your poll has been verified");
|
||||
queryClient.setQueryData(["polls.get", { urlId: poll.urlId }], {
|
||||
queryClient.setQueryData(["polls.get", { urlId, admin }], {
|
||||
...poll,
|
||||
verified: true,
|
||||
});
|
||||
|
@ -78,14 +73,14 @@ const PollPage: NextPage = () => {
|
|||
useMount(() => {
|
||||
const { code } = router.query;
|
||||
if (typeof code === "string" && !poll.verified) {
|
||||
verifyEmail.mutate({ code, pollId: poll.pollId });
|
||||
verifyEmail.mutate({ code, pollId: poll.id });
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (router.query.unsubscribe) {
|
||||
updatePollMutation(
|
||||
{ urlId: poll.urlId, notifications: false },
|
||||
{ urlId: urlId, notifications: false },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Notifications have been disabled");
|
||||
|
@ -97,7 +92,7 @@ const PollPage: NextPage = () => {
|
|||
shallow: true,
|
||||
});
|
||||
}
|
||||
}, [plausible, poll.urlId, router, updatePollMutation]);
|
||||
}, [plausible, urlId, router, updatePollMutation]);
|
||||
|
||||
const checkIfWideScreen = () => window.innerWidth > 640;
|
||||
|
||||
|
@ -120,10 +115,13 @@ const PollPage: NextPage = () => {
|
|||
[participants],
|
||||
);
|
||||
|
||||
const [isSharingVisible, setSharingVisible] = React.useState(
|
||||
!!router.query.sharing,
|
||||
);
|
||||
return (
|
||||
<UserAvatarProvider seed={poll.pollId} names={names}>
|
||||
<UserAvatarProvider seed={poll.id} names={names}>
|
||||
<StandardLayout>
|
||||
<div className="relative max-w-full bg-gray-50 py-4 md:px-4 lg:px-4">
|
||||
<div className="relative max-w-full py-4 md:px-4">
|
||||
<Head>
|
||||
<title>{poll.title}</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
|
@ -134,92 +132,144 @@ const PollPage: NextPage = () => {
|
|||
width: Math.max(768, poll.options.length * 95 + 200 + 160),
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 items-start px-4 md:flex md:space-x-4">
|
||||
<div className="mb-3 grow md:mb-0">
|
||||
<div className="flex flex-col-reverse md:flex-row">
|
||||
<h1
|
||||
data-testid="poll-title"
|
||||
className="mb-2 grow text-3xl leading-tight"
|
||||
{admin ? (
|
||||
<>
|
||||
<div className="mb-4 flex space-x-2 px-4 md:justify-end md:px-0">
|
||||
<NotificationsToggle />
|
||||
<ManagePoll
|
||||
placement={isWideScreen ? "bottom-end" : "bottom-start"}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Share />}
|
||||
onClick={() => {
|
||||
setSharingVisible((value) => !value);
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{isSharingVisible ? (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
height: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
height: "auto",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
height: 0,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{preventWidows(poll.title)}
|
||||
</h1>
|
||||
{poll.role === "admin" ? (
|
||||
<div className="mb-4 flex space-x-2 md:mb-2">
|
||||
<NotificationsToggle />
|
||||
<ManagePoll
|
||||
placement={
|
||||
isWideScreen ? "bottom-end" : "bottom-start"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button type="primary" icon={<Share />}>
|
||||
Share
|
||||
</Button>
|
||||
}
|
||||
placement={isWideScreen ? "bottom-end" : undefined}
|
||||
>
|
||||
<Sharing links={poll.links} />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Sharing
|
||||
onHide={() => {
|
||||
setSharingVisible(false);
|
||||
router.replace(
|
||||
`/admin/${router.query.urlId}`,
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{poll.verified === false ? (
|
||||
<div className="m-4 overflow-hidden rounded-lg border p-4 md:mx-0 md:mt-0">
|
||||
<UnverifiedPollNotice />
|
||||
</div>
|
||||
<PollSubheader />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{!poll.admin && poll.adminUrlId ? (
|
||||
<div className="mb-4 items-center justify-between rounded-lg px-4 md:flex md:space-x-4 md:border md:p-2 md:pl-4">
|
||||
<div className="mb-4 font-medium md:mb-0">
|
||||
Hey {poll.user.name}, looks like you are the owner of this
|
||||
poll.
|
||||
</div>
|
||||
</div>
|
||||
{poll.description ? (
|
||||
<div className="mb-4 whitespace-pre-line bg-white px-4 py-3 text-lg leading-relaxed text-slate-600 shadow-sm md:w-fit md:rounded-xl md:bg-white">
|
||||
<TruncatedLinkify>
|
||||
{preventWidows(poll.description)}
|
||||
</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.location ? (
|
||||
<div className="mb-4 flex items-center px-4">
|
||||
<div>
|
||||
<LocationMarker
|
||||
width={20}
|
||||
className="mr-2 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{poll.closed ? (
|
||||
<div className="mb-4 flex items-center bg-sky-100 py-3 px-4 text-sky-700 shadow-sm md:rounded-lg">
|
||||
<div className="mr-3 rounded-md">
|
||||
<LockClosed className="w-5" />
|
||||
</div>
|
||||
This poll has been locked (voting is disabled)
|
||||
<a href={`/admin/${poll.adminUrlId}`} className="btn-default">
|
||||
Go to admin →
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
||||
<span className="text-xs font-semibold text-slate-500">Key:</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="yes" />
|
||||
<span className="text-xs text-slate-500">Yes</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="ifNeedBe" />
|
||||
<span className="text-xs text-slate-500">If need be</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="no" />
|
||||
<span className="text-xs text-slate-500">No</span>
|
||||
</span>
|
||||
</div>
|
||||
<React.Suspense fallback={<div className="p-4">Loading…</div>}>
|
||||
{participants ? (
|
||||
<div className="mb-4 lg:mb-8">
|
||||
<PollComponent />
|
||||
{poll.closed ? (
|
||||
<div className="flex bg-sky-100 py-3 px-4 text-sky-700 md:mb-4 md:rounded-lg md:shadow-sm">
|
||||
<div className="mr-2 rounded-md">
|
||||
<LockClosed className="w-6" />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="font-medium">This poll has been locked</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="md:card mb-4 border-t bg-white md:overflow-hidden md:p-0">
|
||||
<div className="p-4 md:border-b md:p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div
|
||||
className="mb-1 text-2xl font-semibold text-slate-700 md:text-left md:text-3xl"
|
||||
data-testid="poll-title"
|
||||
>
|
||||
{preventWidows(poll.title)}
|
||||
</div>
|
||||
<PollSubheader />
|
||||
</div>
|
||||
{poll.description ? (
|
||||
<div className="border-primary whitespace-pre-line lg:text-lg">
|
||||
<TruncatedLinkify>
|
||||
{preventWidows(poll.description)}
|
||||
</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.location ? (
|
||||
<div className="lg:text-lg">
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("location")}
|
||||
</div>
|
||||
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="mb-2 text-sm text-slate-500">
|
||||
Possible answers
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="yes" />
|
||||
<span className="text-xs text-slate-500">Yes</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="ifNeedBe" />
|
||||
<span className="text-xs text-slate-500">
|
||||
If need be
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="no" />
|
||||
<span className="text-xs text-slate-500">No</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
{participants ? <PollComponent /> : null}
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
<React.Suspense fallback={<div className="p-4">Loading…</div>}>
|
||||
<Discussion />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,6 @@ import smoothscroll from "smoothscroll-polyfill";
|
|||
import { Button } from "../button";
|
||||
import ArrowLeft from "../icons/arrow-left.svg";
|
||||
import ArrowRight from "../icons/arrow-right.svg";
|
||||
import PlusCircle from "../icons/plus-circle.svg";
|
||||
import { useParticipants } from "../participants-provider";
|
||||
import { usePoll } from "../poll-context";
|
||||
import TimeZonePicker from "../time-zone-picker";
|
||||
|
@ -23,29 +22,25 @@ if (typeof window !== "undefined") {
|
|||
|
||||
const MotionButton = motion(Button);
|
||||
|
||||
const MotionParticipantFormRow = motion(ParticipantRowForm);
|
||||
|
||||
const minSidebarWidth = 180;
|
||||
const minSidebarWidth = 200;
|
||||
|
||||
const Poll: React.VoidFunctionComponent = () => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { poll, targetTimeZone, setTargetTimeZone, options, userAlreadyVoted } =
|
||||
const { poll, options, userAlreadyVoted, targetTimeZone, setTargetTimeZone } =
|
||||
usePoll();
|
||||
|
||||
const { participants } = useParticipants();
|
||||
|
||||
const { timeZone } = poll;
|
||||
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const [editingParticipantId, setEditingParticipantId] =
|
||||
React.useState<string | null>(null);
|
||||
|
||||
const actionColumnWidth = 140;
|
||||
const columnWidth = Math.min(
|
||||
100,
|
||||
130,
|
||||
Math.max(
|
||||
95,
|
||||
90,
|
||||
(width - minSidebarWidth - actionColumnWidth) / options.length,
|
||||
),
|
||||
);
|
||||
|
@ -71,12 +66,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
const maxScrollPosition =
|
||||
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
|
||||
|
||||
const numberOfInvisibleColumns = options.length - numberOfVisibleColumns;
|
||||
|
||||
const [didUsePagination, setDidUsePagination] = React.useState(false);
|
||||
|
||||
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
|
||||
React.useState(!userAlreadyVoted && !poll.closed);
|
||||
const shouldShowNewParticipantForm = !userAlreadyVoted && !poll.closed;
|
||||
|
||||
const pollWidth =
|
||||
sidebarWidth + options.length * columnWidth + actionColumnWidth;
|
||||
|
@ -98,6 +88,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const participantListContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<PollContext.Provider
|
||||
value={{
|
||||
|
@ -112,42 +103,31 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
numberOfColumns: numberOfVisibleColumns,
|
||||
availableSpace,
|
||||
actionColumnWidth,
|
||||
maxScrollPosition,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative min-w-full max-w-full select-none" // Don't add styles like border, margin, padding – that can mess up the sizing calculations
|
||||
style={{ width: `min(${pollWidth}px, calc(100vw - 3rem))` }}
|
||||
className="relative min-w-full max-w-full" // Don't add styles like border, margin, padding – that can mess up the sizing calculations
|
||||
style={{ width: pollWidth }}
|
||||
ref={ref}
|
||||
>
|
||||
<div className=" border-t border-b bg-white shadow-sm md:rounded-lg md:border">
|
||||
<div className="sticky top-12 z-10 rounded-t-lg border-gray-200 bg-white/80 shadow-slate-50 backdrop-blur-md lg:top-0">
|
||||
<div className="flex h-14 items-center justify-end space-x-4 rounded-t-lg border-b bg-gray-50 px-4">
|
||||
{timeZone ? (
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-2 text-sm font-medium text-slate-500">
|
||||
{t("timeZone")}
|
||||
</div>
|
||||
<TimeZonePicker
|
||||
value={targetTimeZone}
|
||||
onChange={setTargetTimeZone}
|
||||
className="grow"
|
||||
/>
|
||||
<div className="flex max-h-[calc(100vh-70px)] flex-col overflow-hidden bg-white">
|
||||
{poll.timeZone ? (
|
||||
<div className="flex h-14 items-center justify-end space-x-4 border-b bg-gray-50 px-4">
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-2 text-sm font-medium text-slate-500">
|
||||
{t("timeZone")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={shouldShowNewParticipantForm || poll.closed}
|
||||
icon={<PlusCircle />}
|
||||
onClick={() => {
|
||||
setShouldShowNewParticipantForm(true);
|
||||
}}
|
||||
>
|
||||
New Participant
|
||||
</Button>
|
||||
<TimeZonePicker
|
||||
value={targetTimeZone}
|
||||
onChange={setTargetTimeZone}
|
||||
className="grow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
) : null}
|
||||
<div>
|
||||
<div className="flex border-b py-2">
|
||||
<div
|
||||
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
|
||||
style={{ width: sidebarWidth }}
|
||||
|
@ -186,57 +166,55 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
className="text-xs"
|
||||
rounded={true}
|
||||
onClick={() => {
|
||||
setDidUsePagination(true);
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
{didUsePagination ? (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
) : (
|
||||
`+${numberOfInvisibleColumns} more…`
|
||||
)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</MotionButton>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowNewParticipantForm && !poll.closed ? (
|
||||
<MotionParticipantFormRow
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 55, y: 0 }}
|
||||
className="border-t border-b bg-gray-50"
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
await addParticipant.mutateAsync({
|
||||
name,
|
||||
votes,
|
||||
pollId: poll.pollId,
|
||||
});
|
||||
setShouldShowNewParticipantForm(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{participants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
editMode={editingParticipantId === participant.id}
|
||||
onChangeEditMode={(isEditing) => {
|
||||
setEditingParticipantId(isEditing ? participant.id : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{participants.length > 0 ? (
|
||||
<div
|
||||
className="min-h-0 overflow-y-auto py-2"
|
||||
ref={participantListContainerRef}
|
||||
>
|
||||
{participants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
editMode={editingParticipantId === participant.id}
|
||||
onChangeEditMode={(isEditing) => {
|
||||
setEditingParticipantId(
|
||||
isEditing ? participant.id : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowNewParticipantForm ? (
|
||||
<ParticipantRowForm
|
||||
className="border-t bg-gray-50"
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
const participant = await addParticipant.mutateAsync({
|
||||
name,
|
||||
votes,
|
||||
pollId: poll.id,
|
||||
});
|
||||
setTimeout(() => {
|
||||
participantListContainerRef.current
|
||||
?.querySelector(`[data-participantid=${participant.id}]`)
|
||||
?.scrollIntoView();
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PollContext.Provider>
|
||||
|
|
|
@ -33,6 +33,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
sidebarWidth,
|
||||
numberOfColumns,
|
||||
goToNextPage,
|
||||
maxScrollPosition,
|
||||
setScrollPosition,
|
||||
} = usePollContext();
|
||||
|
||||
|
@ -85,9 +86,8 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
render={({ field }) => (
|
||||
<div className="w-full">
|
||||
<NameInput
|
||||
autoFocus={true}
|
||||
className={clsx("w-full", {
|
||||
"input-error animate-wiggle": errors.name && submitCount > 0,
|
||||
"input-error": errors.name && submitCount > 0,
|
||||
})}
|
||||
placeholder="Your name"
|
||||
{...field}
|
||||
|
@ -160,16 +160,28 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
icon={<Check />}
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
data-testid="submitNewParticipant"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<CompactButton onClick={onCancel} icon={X} />
|
||||
{scrollPosition >= maxScrollPosition ? (
|
||||
<Button
|
||||
htmlType="submit"
|
||||
icon={<Check />}
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
data-testid="submitNewParticipant"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
{scrollPosition < maxScrollPosition ? (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancel ? <CompactButton onClick={onCancel} icon={X} /> : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
|
@ -32,6 +32,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
|||
columnWidth: number;
|
||||
sidebarWidth: number;
|
||||
isYou?: boolean;
|
||||
participantId: string;
|
||||
}> = ({
|
||||
name,
|
||||
editable,
|
||||
|
@ -42,9 +43,14 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
|||
columnWidth,
|
||||
isYou,
|
||||
color,
|
||||
participantId,
|
||||
}) => {
|
||||
return (
|
||||
<div data-testid="participant-row" className="group flex h-14">
|
||||
<div
|
||||
data-testid="participant-row"
|
||||
data-participantid={participantId}
|
||||
className="group flex h-14"
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center px-4"
|
||||
style={{ width: sidebarWidth }}
|
||||
|
@ -109,8 +115,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
|
||||
const isAnonymous = !participant.userId && !participant.guestId;
|
||||
|
||||
const canEdit =
|
||||
!poll.closed && (poll.role === "admin" || isYou || isAnonymous);
|
||||
const canEdit = !poll.closed && (poll.admin || isYou || isAnonymous);
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
|
@ -125,7 +130,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
onSubmit={async ({ name, votes }) => {
|
||||
await updateParticipant.mutateAsync({
|
||||
participantId: participant.id,
|
||||
pollId: poll.pollId,
|
||||
pollId: poll.id,
|
||||
votes,
|
||||
name,
|
||||
});
|
||||
|
@ -144,6 +149,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
votes={options.map(({ optionId }) => {
|
||||
return getVote(participant.id, optionId);
|
||||
})}
|
||||
participantId={participant.id}
|
||||
editable={canEdit}
|
||||
isYou={isYou}
|
||||
onEdit={() => {
|
||||
|
|
|
@ -5,6 +5,7 @@ export const PollContext = React.createContext<{
|
|||
activeOptionId: string | null;
|
||||
setActiveOptionId: (optionId: string | null) => void;
|
||||
scrollPosition: number;
|
||||
maxScrollPosition: number;
|
||||
setScrollPosition: (position: number) => void;
|
||||
columnWidth: number;
|
||||
sidebarWidth: number;
|
||||
|
@ -17,6 +18,7 @@ export const PollContext = React.createContext<{
|
|||
activeOptionId: null,
|
||||
setActiveOptionId: noop,
|
||||
scrollPosition: 0,
|
||||
maxScrollPosition: 100,
|
||||
setScrollPosition: noop,
|
||||
columnWidth: 100,
|
||||
sidebarWidth: 200,
|
||||
|
|
|
@ -43,12 +43,12 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
|||
onMouseOut={() => setActiveOptionId(null)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold leading-9">
|
||||
<div className="text-sm uppercase text-slate-400">
|
||||
<div className="leading-9">
|
||||
<div className="text-xs font-semibold uppercase text-slate-500/75">
|
||||
{option.dow}
|
||||
</div>
|
||||
<div className="text-2xl">{option.day}</div>
|
||||
<div className="text-xs font-medium uppercase text-slate-400/75">
|
||||
<div className="text-2xl font-semibold">{option.day}</div>
|
||||
<div className="text-xs font-medium uppercase text-slate-500/50">
|
||||
{option.month}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -27,7 +27,8 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
placement?: Placement;
|
||||
}> = ({ placement }) => {
|
||||
const { t } = useTranslation("app");
|
||||
const { poll, getParticipantsWhoVotedForOption, setDeleted } = usePoll();
|
||||
const { poll, getParticipantsWhoVotedForOption, setDeleted, urlId } =
|
||||
usePoll();
|
||||
|
||||
const { exportToCsv } = useCsvExporter();
|
||||
|
||||
|
@ -98,7 +99,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
const onOk = () => {
|
||||
updatePollMutation(
|
||||
{
|
||||
urlId: poll.urlId,
|
||||
urlId: urlId,
|
||||
timeZone: data.timeZone,
|
||||
optionsToDelete: optionsToDelete.map(({ id }) => id),
|
||||
optionsToAdd,
|
||||
|
@ -165,7 +166,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
onSubmit={(data) => {
|
||||
//submit
|
||||
updatePollMutation(
|
||||
{ urlId: poll.urlId, ...data },
|
||||
{ urlId, ...data },
|
||||
{ onSuccess: closePollDetailsModal },
|
||||
);
|
||||
}}
|
||||
|
@ -195,17 +196,13 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
<DropdownItem
|
||||
icon={LockOpen}
|
||||
label="Unlock poll"
|
||||
onClick={() =>
|
||||
updatePollMutation({ urlId: poll.urlId, closed: false })
|
||||
}
|
||||
onClick={() => updatePollMutation({ urlId, closed: false })}
|
||||
/>
|
||||
) : (
|
||||
<DropdownItem
|
||||
icon={LockClosed}
|
||||
label="Lock poll"
|
||||
onClick={() =>
|
||||
updatePollMutation({ urlId: poll.urlId, closed: true })
|
||||
}
|
||||
onClick={() => updatePollMutation({ urlId, closed: true })}
|
||||
/>
|
||||
)}
|
||||
<DropdownItem
|
||||
|
@ -221,7 +218,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
setDeleted(true);
|
||||
}}
|
||||
onCancel={close}
|
||||
urlId={poll.urlId}
|
||||
urlId={urlId}
|
||||
/>
|
||||
),
|
||||
footer: null,
|
||||
|
|
|
@ -10,7 +10,6 @@ import Check from "@/components/icons/check.svg";
|
|||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||
import PlusCircle from "@/components/icons/plus-circle.svg";
|
||||
import Save from "@/components/icons/save.svg";
|
||||
import Trash from "@/components/icons/trash.svg";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
|
||||
|
@ -45,10 +44,11 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
getParticipantById,
|
||||
optionIds,
|
||||
getVote,
|
||||
userAlreadyVoted,
|
||||
} = pollContext;
|
||||
|
||||
const { participants } = useParticipants();
|
||||
const { timeZone, role } = poll;
|
||||
const { timeZone } = poll;
|
||||
|
||||
const session = useSession();
|
||||
|
||||
|
@ -60,38 +60,34 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
});
|
||||
|
||||
const { reset, handleSubmit, control, formState } = form;
|
||||
const [selectedParticipantId, setSelectedParticipantId] =
|
||||
React.useState<string>();
|
||||
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
|
||||
string | undefined
|
||||
>(() => {
|
||||
if (poll.admin) {
|
||||
// don't select a particpant if admin
|
||||
return;
|
||||
}
|
||||
const { user } = session;
|
||||
if (user) {
|
||||
const userParticipant = participants.find((participant) =>
|
||||
user.isGuest
|
||||
? participant.guestId === user.id
|
||||
: participant.userId === user.id,
|
||||
);
|
||||
return userParticipant?.id;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedParticipant = selectedParticipantId
|
||||
? getParticipantById(selectedParticipantId)
|
||||
: undefined;
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(
|
||||
!userAlreadyVoted && !poll.closed && !poll.admin,
|
||||
);
|
||||
|
||||
const [shouldShowSaveButton, setShouldShowSaveButton] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const setState = () => {
|
||||
if (formRef.current) {
|
||||
const rect = formRef.current.getBoundingClientRect();
|
||||
const saveButtonIsVisible = rect.bottom <= window.innerHeight;
|
||||
|
||||
setShouldShowSaveButton(
|
||||
!saveButtonIsVisible &&
|
||||
formRef.current.getBoundingClientRect().top <
|
||||
window.innerHeight / 2,
|
||||
);
|
||||
}
|
||||
};
|
||||
setState();
|
||||
window.addEventListener("scroll", setState, true);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", setState, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
|
@ -99,20 +95,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
const addParticipant = useAddParticipantMutation();
|
||||
const confirmDeleteParticipant = useDeleteParticipantModal();
|
||||
|
||||
const submitContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollToSave = () => {
|
||||
if (submitContainerRef.current) {
|
||||
window.scrollTo({
|
||||
top:
|
||||
document.documentElement.scrollTop +
|
||||
submitContainerRef.current.getBoundingClientRect().bottom -
|
||||
window.innerHeight +
|
||||
100,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
|
@ -121,7 +103,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
onSubmit={handleSubmit(async ({ name, votes }) => {
|
||||
if (selectedParticipant) {
|
||||
await updateParticipant.mutateAsync({
|
||||
pollId: poll.pollId,
|
||||
pollId: poll.id,
|
||||
participantId: selectedParticipant.id,
|
||||
name,
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
|
@ -129,7 +111,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
setIsEditing(false);
|
||||
} else {
|
||||
const newParticipant = await addParticipant.mutateAsync({
|
||||
pollId: poll.pollId,
|
||||
pollId: poll.id,
|
||||
name,
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
});
|
||||
|
@ -140,65 +122,84 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
>
|
||||
<div className="sticky top-[47px] z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
|
||||
<div className="flex space-x-3">
|
||||
<Listbox
|
||||
value={selectedParticipantId}
|
||||
onChange={(participantId) => {
|
||||
setSelectedParticipantId(participantId);
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<div className="menu min-w-0 grow">
|
||||
<Listbox.Button
|
||||
as={Button}
|
||||
className="w-full"
|
||||
disabled={!isEditing}
|
||||
data-testid="participant-selector"
|
||||
>
|
||||
<div className="min-w-0 grow text-left">
|
||||
{selectedParticipant ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
name={selectedParticipant.name}
|
||||
showName={true}
|
||||
isYou={session.ownsObject(selectedParticipant)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
t("participantCount", { count: participants.length })
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-5 shrink-0" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options
|
||||
as={motion.div}
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="menu-items max-h-72 w-full overflow-auto"
|
||||
>
|
||||
<Listbox.Option value={undefined} className={styleMenuItem}>
|
||||
{t("participantCount", { count: participants.length })}
|
||||
</Listbox.Option>
|
||||
{participants.map((participant) => (
|
||||
<Listbox.Option
|
||||
key={participant.id}
|
||||
value={participant.id}
|
||||
className={styleMenuItem}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
name={participant.name}
|
||||
showName={true}
|
||||
isYou={session.ownsObject(participant)}
|
||||
/>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<Listbox
|
||||
value={selectedParticipantId}
|
||||
onChange={(participantId) => {
|
||||
setSelectedParticipantId(participantId);
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<div className="menu min-w-0 grow">
|
||||
<Listbox.Button
|
||||
as={Button}
|
||||
className="w-full"
|
||||
disabled={!isEditing}
|
||||
data-testid="participant-selector"
|
||||
>
|
||||
<div className="min-w-0 grow text-left">
|
||||
{selectedParticipant ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
name={selectedParticipant.name}
|
||||
showName={true}
|
||||
isYou={session.ownsObject(selectedParticipant)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
t("participantCount", { count: participants.length })
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-5 shrink-0" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options
|
||||
as={motion.div}
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="menu-items max-h-72 w-full overflow-auto"
|
||||
>
|
||||
<Listbox.Option value={undefined} className={styleMenuItem}>
|
||||
{t("participantCount", { count: participants.length })}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
{participants.map((participant) => (
|
||||
<Listbox.Option
|
||||
key={participant.id}
|
||||
value={participant.id}
|
||||
className={styleMenuItem}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
name={participant.name}
|
||||
showName={true}
|
||||
isYou={session.ownsObject(participant)}
|
||||
/>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
) : (
|
||||
<div className="grow">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ validate: requiredString }}
|
||||
render={({ field }) => (
|
||||
<NameInput
|
||||
disabled={formState.isSubmitting}
|
||||
className={clsx("input w-full", {
|
||||
"input-error": formState.errors.name,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Listbox>
|
||||
)}
|
||||
{isEditing ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
@ -215,7 +216,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
disabled={
|
||||
poll.closed ||
|
||||
// if user is participant (not admin)
|
||||
(role === "participant" &&
|
||||
(!poll.admin &&
|
||||
// and does not own this participant
|
||||
!session.ownsObject(selectedParticipant) &&
|
||||
// and the participant has been claimed by a different user
|
||||
|
@ -240,7 +241,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
disabled={
|
||||
poll.closed ||
|
||||
// if user is participant (not admin)
|
||||
(role === "participant" &&
|
||||
(!poll.admin &&
|
||||
// and does not own this participant
|
||||
!session.ownsObject(selectedParticipant) &&
|
||||
// or the participant has been claimed by a different user
|
||||
|
@ -256,7 +257,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : !userAlreadyVoted ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusCircle />}
|
||||
|
@ -271,7 +272,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
>
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{timeZone ? (
|
||||
<TimeZonePicker
|
||||
|
@ -294,28 +295,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
return `${option.month} ${option.year}`;
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{shouldShowSaveButton && isEditing ? (
|
||||
<motion.button
|
||||
type="button"
|
||||
variants={{
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -50,
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: { opacity: 1, y: 0, transition: { delay: 0.2 } },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="fixed bottom-8 left-1/2 z-10 -ml-6 inline-flex h-12 w-12 appearance-none items-center justify-center rounded-full bg-white text-slate-700 shadow-lg active:bg-gray-100"
|
||||
>
|
||||
<Save className="w-5" onClick={scrollToSave} />
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
|
@ -332,36 +311,16 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
transition: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={submitContainerRef}
|
||||
className="space-y-3 border-t bg-gray-50 p-3"
|
||||
>
|
||||
<div className="flex space-x-3">
|
||||
<div className="grow">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ validate: requiredString }}
|
||||
render={({ field }) => (
|
||||
<NameInput
|
||||
disabled={formState.isSubmitting}
|
||||
className={clsx("input w-full", {
|
||||
"input-error": formState.errors.name,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<Check />}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 border-t bg-gray-50 p-3">
|
||||
<Button
|
||||
icon={<Check />}
|
||||
className="w-full"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
|
|
@ -29,7 +29,7 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
|||
className?: string;
|
||||
}> = ({ className, children, expanded }) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
variants={{
|
||||
|
@ -92,7 +92,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
|||
<div>
|
||||
{noVotes ? (
|
||||
<div className="rounded-lg bg-slate-50 p-2 text-center text-slate-400">
|
||||
No one has vote for this option
|
||||
No one has voted for this option
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
|
|
|
@ -26,7 +26,7 @@ export const useAddParticipantMutation = () => {
|
|||
queryClient.setQueryData(
|
||||
["polls.participants.list", { pollId: participant.pollId }],
|
||||
(existingParticipants = []) => {
|
||||
return [participant, ...existingParticipants];
|
||||
return [...existingParticipants, participant];
|
||||
},
|
||||
);
|
||||
session.refresh();
|
||||
|
@ -79,12 +79,12 @@ export const useDeleteParticipantMutation = () => {
|
|||
};
|
||||
|
||||
export const useUpdatePollMutation = () => {
|
||||
const { poll } = usePoll();
|
||||
const { urlId, admin } = usePoll();
|
||||
const plausible = usePlausible();
|
||||
const queryClient = trpc.useContext();
|
||||
return trpc.useMutation(["polls.update"], {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["polls.get", { urlId: poll.urlId }], data);
|
||||
queryClient.setQueryData(["polls.get", { urlId, admin }], data);
|
||||
plausible("Updated poll");
|
||||
},
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ import Tooltip from "../tooltip";
|
|||
import { useUpdatePollMutation } from "./mutations";
|
||||
|
||||
const NotificationsToggle: React.VoidFunctionComponent = () => {
|
||||
const { poll } = usePoll();
|
||||
const { poll, urlId } = usePoll();
|
||||
const { t } = useTranslation("app");
|
||||
const [isUpdatingNotifications, setIsUpdatingNotifications] =
|
||||
React.useState(false);
|
||||
|
@ -25,7 +25,7 @@ const NotificationsToggle: React.VoidFunctionComponent = () => {
|
|||
poll.verified ? (
|
||||
poll.notifications ? (
|
||||
<div>
|
||||
<div className="text-primary-300 font-medium">
|
||||
<div className="font-medium text-primary-300">
|
||||
Notifications are on
|
||||
</div>
|
||||
<div className="max-w-sm">
|
||||
|
@ -37,7 +37,7 @@ const NotificationsToggle: React.VoidFunctionComponent = () => {
|
|||
}}
|
||||
components={{
|
||||
b: (
|
||||
<span className="text-primary-300 whitespace-nowrap font-mono font-medium " />
|
||||
<span className="whitespace-nowrap font-mono font-medium text-primary-300 " />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
@ -59,7 +59,7 @@ const NotificationsToggle: React.VoidFunctionComponent = () => {
|
|||
setIsUpdatingNotifications(true);
|
||||
updatePollMutation(
|
||||
{
|
||||
urlId: poll.urlId,
|
||||
urlId,
|
||||
notifications: !poll.notifications,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -2,11 +2,8 @@ import { formatRelative } from "date-fns";
|
|||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
|
||||
import { trpc } from "../../utils/trpc";
|
||||
import Badge from "../badge";
|
||||
import { Button } from "../button";
|
||||
import { usePoll } from "../poll-context";
|
||||
import Popover from "../popover";
|
||||
import { usePreferences } from "../preferences/use-preferences";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
|
@ -14,12 +11,9 @@ const PollSubheader: React.VoidFunctionComponent = () => {
|
|||
const { poll } = usePoll();
|
||||
const { t } = useTranslation("app");
|
||||
const { locale } = usePreferences();
|
||||
const requestVerificationEmail = trpc.useMutation(
|
||||
"polls.verification.request",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-slate-500">
|
||||
<div className="text-slate-500/75 lg:text-lg">
|
||||
<div className="md:inline">
|
||||
<Trans
|
||||
i18nKey="createdBy"
|
||||
|
@ -28,74 +22,30 @@ const PollSubheader: React.VoidFunctionComponent = () => {
|
|||
name: poll.authorName,
|
||||
}}
|
||||
components={{
|
||||
b: <span className="text-primary-500 font-medium" />,
|
||||
b: <span />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
{poll.role === "admin" && !poll.demo ? (
|
||||
poll.verified ? (
|
||||
<Badge color="green">Verified</Badge>
|
||||
) : (
|
||||
<Popover
|
||||
trigger={
|
||||
<button className="inline-flex h-5 items-center rounded-md bg-slate-200 px-1 text-xs text-slate-500 transition-colors hover:bg-slate-300 hover:shadow-sm active:bg-slate-200">
|
||||
Unverified
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-w-sm">
|
||||
<div className="mb-4">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="unverifiedMessage"
|
||||
values={{ email: poll.user.email }}
|
||||
components={{
|
||||
b: (
|
||||
<span className="text-primary-500 whitespace-nowrap font-mono font-medium" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{requestVerificationEmail.isSuccess ? (
|
||||
<div className="text-green-500">
|
||||
Verification email sent.
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
requestVerificationEmail.mutate({
|
||||
pollId: poll.pollId,
|
||||
adminUrlId: poll.urlId,
|
||||
});
|
||||
}}
|
||||
loading={requestVerificationEmail.isLoading}
|
||||
>
|
||||
Resend verification email
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
) : null}
|
||||
{poll.legacy && poll.role === "admin" ? (
|
||||
<Tooltip
|
||||
width={400}
|
||||
content="This poll was created with an older version of Rallly. Some features might not work."
|
||||
>
|
||||
<Badge color="amber">Legacy</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{poll.demo ? (
|
||||
<Tooltip content={<Trans t={t} i18nKey="demoPollNotice" />}>
|
||||
<Badge color="blue">Demo</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
{poll.legacy && poll.admin ? (
|
||||
<Tooltip
|
||||
width={400}
|
||||
content="This poll was created with an older version of Rallly. Some features might not work."
|
||||
>
|
||||
<Badge color="amber" className="ml-1">
|
||||
Legacy
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{poll.demo ? (
|
||||
<Tooltip content={<Trans t={t} i18nKey="demoPollNotice" />}>
|
||||
<Badge color="blue" className="ml-1">
|
||||
Demo
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="hidden md:inline"> • </span>
|
||||
<span className="whitespace-nowrap">
|
||||
{formatRelative(new Date(poll.createdAt), new Date(), {
|
||||
{formatRelative(poll.createdAt, new Date(), {
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
|
|
46
src/components/poll/unverified-poll-notice.tsx
Normal file
46
src/components/poll/unverified-poll-notice.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { Trans, useTranslation } from "next-i18next";
|
||||
|
||||
import { trpc } from "../../utils/trpc";
|
||||
import { Button } from "../button";
|
||||
import { usePoll } from "../poll-context";
|
||||
|
||||
export const UnverifiedPollNotice = () => {
|
||||
const { t } = useTranslation("app");
|
||||
const { poll } = usePoll();
|
||||
const requestVerificationEmail = trpc.useMutation(
|
||||
"polls.verification.request",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="md:flex md:justify-between md:space-x-4">
|
||||
<div className="mb-4 md:mb-0 md:w-2/3">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="unverifiedMessage"
|
||||
values={{ email: poll.user.email }}
|
||||
components={{
|
||||
b: (
|
||||
<span className="whitespace-nowrap font-medium text-slate-700" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
requestVerificationEmail.mutate({
|
||||
pollId: poll.id,
|
||||
adminUrlId: poll.adminUrlId,
|
||||
});
|
||||
}}
|
||||
disabled={requestVerificationEmail.isSuccess}
|
||||
loading={requestVerificationEmail.isLoading}
|
||||
>
|
||||
{requestVerificationEmail.isSuccess
|
||||
? "Vertification email sent"
|
||||
: "Resend verification email"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -6,9 +6,7 @@ export const useDeleteParticipantModal = () => {
|
|||
const { render } = useModalContext();
|
||||
|
||||
const deleteParticipant = useDeleteParticipantMutation();
|
||||
const {
|
||||
poll: { pollId },
|
||||
} = usePoll();
|
||||
const { poll } = usePoll();
|
||||
|
||||
return (participantId: string) => {
|
||||
return render({
|
||||
|
@ -21,7 +19,7 @@ export const useDeleteParticipantModal = () => {
|
|||
okText: "Delete",
|
||||
onOk: () => {
|
||||
deleteParticipant.mutate({
|
||||
pollId,
|
||||
pollId: poll.id,
|
||||
participantId,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -35,8 +35,8 @@ export const Profile: React.VoidFunctionComponent = () => {
|
|||
return (
|
||||
<div className="mx-auto max-w-3xl py-4 lg:mx-0">
|
||||
<div className="mb-4 flex items-center px-4">
|
||||
<div className="bg-primary-50 mr-4 inline-flex h-14 w-14 items-center justify-center rounded-lg">
|
||||
<User className="text-primary-500 h-7" />
|
||||
<div className="mr-4 inline-flex h-14 w-14 items-center justify-center rounded-lg bg-primary-50">
|
||||
<User className="h-7 text-primary-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
|
@ -71,9 +71,9 @@ export const Profile: React.VoidFunctionComponent = () => {
|
|||
<div className="sm:table-cell sm:p-4">
|
||||
<div>
|
||||
<div className="flex">
|
||||
<Calendar className="text-primary-500 mr-2 mt-[1px] h-5" />
|
||||
<Link href={`/admin/${poll.links[0].urlId}`}>
|
||||
<a className="hover:text-primary-500 text-slate-700 hover:no-underline">
|
||||
<Calendar className="mr-2 mt-[1px] h-5 text-primary-500" />
|
||||
<Link href={`/admin/${poll.adminUrlId}`}>
|
||||
<a className="text-slate-700 hover:text-primary-500 hover:no-underline">
|
||||
<div>{poll.title}</div>
|
||||
</a>
|
||||
</Link>
|
||||
|
|
|
@ -1,102 +1,79 @@
|
|||
import { Link, Role } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { usePoll } from "./poll-context";
|
||||
|
||||
export interface SharingProps {
|
||||
links: Link[];
|
||||
onHide: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const useRoleData = (): Record<
|
||||
Role,
|
||||
{ path: string; label: string; description: string }
|
||||
> => {
|
||||
const Sharing: React.VoidFunctionComponent<SharingProps> = ({
|
||||
onHide,
|
||||
className,
|
||||
}) => {
|
||||
const { poll } = usePoll();
|
||||
const { t } = useTranslation("app");
|
||||
return {
|
||||
admin: {
|
||||
path: "admin",
|
||||
label: t("admin"),
|
||||
description: t("adminDescription"),
|
||||
},
|
||||
participant: {
|
||||
path: "p",
|
||||
label: t("participant"),
|
||||
description: t("participantDescription"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const Sharing: React.VoidFunctionComponent<SharingProps> = ({ links }) => {
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const plausible = usePlausible();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(`Unable to copy value: ${state.error.message}`);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const [role, setRole] = React.useState<Role>("participant");
|
||||
const link = links.find((link) => link.role === role);
|
||||
if (!link) {
|
||||
throw new Error(`Missing link for role: ${role}`);
|
||||
}
|
||||
const roleData = useRoleData();
|
||||
const { path } = roleData[link.role];
|
||||
const pollUrl = `${window.location.origin}/${path}/${link.urlId}`;
|
||||
const participantUrl = `${window.location.origin}/p/${poll.participantUrlId}`;
|
||||
const [didCopy, setDidCopy] = React.useState(false);
|
||||
return (
|
||||
<div className="w-[300px] md:w-[400px]">
|
||||
<div className="segment-button mb-3 inline-flex">
|
||||
<div className={clsx("card p-4", className)}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-slate-700">
|
||||
Share via link
|
||||
</div>
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": role === "participant",
|
||||
})}
|
||||
onClick={() => {
|
||||
setRole("participant");
|
||||
}}
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="h-8 items-center justify-center rounded-md px-3 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 active:bg-slate-500/20"
|
||||
>
|
||||
{roleData["participant"].label}
|
||||
</button>
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": role === "admin",
|
||||
})}
|
||||
onClick={() => {
|
||||
setRole("admin");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{roleData["admin"].label}
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-col space-y-3 lg:flex-row lg:space-y-0 lg:space-x-3">
|
||||
<input readOnly={true} className="input lg:w-[280px]" value={pollUrl} />
|
||||
<div className="mb-4 text-slate-600">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="shareDescription"
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly={true}
|
||||
className={clsx(
|
||||
"mb-4 w-full rounded-md bg-gray-100 p-2 text-slate-600 transition-all md:mb-0 md:p-3 md:text-lg",
|
||||
{
|
||||
"bg-slate-50 opacity-75": didCopy,
|
||||
},
|
||||
)}
|
||||
value={participantUrl}
|
||||
/>
|
||||
<Button
|
||||
className="w-24 shrink-0"
|
||||
disabled={didCopy}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
copyToClipboard(pollUrl);
|
||||
copyToClipboard(participantUrl);
|
||||
setDidCopy(true);
|
||||
setTimeout(() => setDidCopy(false), 1000);
|
||||
plausible("Copied share link", {
|
||||
props: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
setDidCopy(false);
|
||||
}, 1000);
|
||||
}}
|
||||
className="md:absolute md:top-1/2 md:right-3 md:-translate-y-1/2"
|
||||
>
|
||||
{didCopy ? "Copied" : "Copy Link"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-slate-500">{roleData[link.role].description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -30,7 +30,7 @@ const HomeLink = () => {
|
|||
return (
|
||||
<Link href="/">
|
||||
<a>
|
||||
<Logo className="text-primary-500 active:text-primary-600 inline-block w-28 transition-colors lg:w-32" />
|
||||
<Logo className="inline-block w-28 text-primary-500 transition-colors active:text-primary-600 lg:w-32" />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
@ -41,7 +41,10 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
|||
}> = ({ openLoginModal }) => {
|
||||
const { user } = useSession();
|
||||
return (
|
||||
<div className="fixed top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 lg:hidden">
|
||||
<div
|
||||
className="fixed top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50
|
||||
px-4 lg:hidden"
|
||||
>
|
||||
<div>
|
||||
<HomeLink />
|
||||
</div>
|
||||
|
@ -72,7 +75,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
|||
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">
|
||||
<UserCircle className="group-hover:text-primary-500 w-5 opacity-75 group-hover:opacity-100" />
|
||||
<UserCircle className="w-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="hidden max-w-[120px] truncate font-medium xs:block">
|
||||
{user.shortName}
|
||||
|
@ -89,7 +92,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
|||
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="group-hover:text-primary-500 h-5 opacity-75" />
|
||||
<Adjustments className="h-5 opacity-75 group-hover:text-primary-500" />
|
||||
<span className="ml-2 hidden sm:block">Preferences</span>
|
||||
</button>
|
||||
}
|
||||
|
@ -103,7 +106,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
|||
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="group-hover:text-primary-500 w-5" />
|
||||
<Menu className="w-5 group-hover:text-primary-500" />
|
||||
<span className="ml-2 hidden sm:block">Menu</span>
|
||||
</button>
|
||||
}
|
||||
|
@ -160,7 +163,7 @@ const UserDropdown: React.VoidFunctionComponent<
|
|||
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="to-primary-500 inline-flex h-20 w-20 items-center justify-center rounded-full border-8 border-white bg-gradient-to-b from-purple-400">
|
||||
<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-primary-500">
|
||||
<User className="h-7 text-white" />
|
||||
</div>
|
||||
<div className="">
|
||||
|
@ -251,7 +254,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
<div className="mb-4">
|
||||
<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">
|
||||
<Pencil className="group-hover:text-primary-500 h-5 opacity-75 group-hover:opacity-100" />
|
||||
<Pencil className="h-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
|
||||
<span className="grow text-left">New Poll</span>
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -261,14 +264,14 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
className="group mb-1 flex items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Support className="group-hover:text-primary-500 h-5 opacity-75 group-hover:opacity-100" />
|
||||
<Support className="h-5 opacity-75 group-hover:text-primary-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="group-hover:text-primary-500 h-5 opacity-75 group-hover:opacity-100" />
|
||||
<Adjustments className="h-5 opacity-75 group-hover:text-primary-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>
|
||||
|
@ -281,7 +284,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
onClick={openLoginModal}
|
||||
className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20"
|
||||
>
|
||||
<Login className="group-hover:text-primary-500 h-5 opacity-75 group-hover:opacity-100" />
|
||||
<Login className="h-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
|
||||
<span className="grow text-left">Login</span>
|
||||
</button>
|
||||
)}
|
||||
|
@ -301,7 +304,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
>
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
<div className="relative">
|
||||
<UserCircle className="group-hover:text-primary-500 h-5 opacity-75 group-hover:opacity-100" />
|
||||
<UserCircle className="h-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
<div className="truncate font-medium leading-snug text-slate-600">
|
||||
|
@ -327,7 +330,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
<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>
|
||||
<Link href="https://rallly.co">
|
||||
<a className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
|
||||
<Logo className="h-5" />
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -337,30 +340,30 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
<a
|
||||
target="_blank"
|
||||
href="https://support.rallly.co"
|
||||
className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline"
|
||||
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Support
|
||||
</a>
|
||||
<Link href="https://github.com/lukevella/rallly/discussions">
|
||||
<a className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
|
||||
Discussions
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://blog.rallly.co">
|
||||
<a className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
|
||||
Blog
|
||||
</a>
|
||||
</Link>
|
||||
<div className="hidden text-slate-300 lg:block">•</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="https://twitter.com/ralllyco">
|
||||
<a className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
|
||||
<Twitter className="h-5 w-5" />
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://github.com/lukevella/rallly">
|
||||
<a className="hover:text-primary-500 text-sm text-slate-400 transition-colors hover:no-underline">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
|
||||
<Github className="h-5 w-5" />
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -368,7 +371,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
|||
</div>
|
||||
<div className="hidden text-slate-300 lg:block">•</div>
|
||||
<Link href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E">
|
||||
<a className="hover:bg-primary-500 focus:ring-primary-500 active:bg-primary-600 inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:text-white hover:no-underline focus:ring-2 focus:ring-offset-1">
|
||||
<a className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-primary-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 active:bg-primary-600">
|
||||
<Cash className="mr-1 inline-block w-5" />
|
||||
<span>Donate</span>
|
||||
</a>
|
||||
|
|
|
@ -7,18 +7,28 @@ export interface TextInputProps
|
|||
HTMLInputElement
|
||||
> {
|
||||
error?: boolean;
|
||||
proportions?: "lg" | "md";
|
||||
}
|
||||
|
||||
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
||||
function TextInput({ className, error, ...forwardProps }, ref) {
|
||||
function TextInput(
|
||||
{ className, error, proportions: size = "md", ...forwardProps },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
className={clsx("input", className, {
|
||||
"input-error": error,
|
||||
"bg-slate-50 text-slate-500": forwardProps.disabled,
|
||||
})}
|
||||
className={clsx(
|
||||
"appearance-none rounded-md border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500",
|
||||
className,
|
||||
{
|
||||
"px-2 py-1": size === "md",
|
||||
"px-3 py-2 text-xl": size === "lg",
|
||||
"input-error": error,
|
||||
"bg-slate-50 text-slate-500": forwardProps.disabled,
|
||||
},
|
||||
)}
|
||||
{...forwardProps}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
import {
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
size,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
@ -119,6 +126,23 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
|||
}> = ({ value, onChange, onBlur, className, style, disabled }) => {
|
||||
const { options, findFuzzyTz } = useTimeZones();
|
||||
|
||||
const { reference, floating, x, y, strategy, refs } = useFloating({
|
||||
strategy: "fixed",
|
||||
middleware: [
|
||||
offset(5),
|
||||
flip(),
|
||||
size({
|
||||
apply: ({ reference }) => {
|
||||
if (refs.floating.current) {
|
||||
Object.assign(refs.floating.current.style, {
|
||||
width: `${reference.width}px`,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const timeZoneOptions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
|
@ -164,7 +188,11 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
|||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={clsx("relative", className)} style={style}>
|
||||
<div
|
||||
className={clsx("relative", className)}
|
||||
ref={reference}
|
||||
style={style}
|
||||
>
|
||||
{/* Remove generic params once Combobox.Input can infer the types */}
|
||||
<Combobox.Input<"input", TimeZoneOption>
|
||||
className="input w-full pr-8"
|
||||
|
@ -182,17 +210,27 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
|||
<ChevronDown className="h-5 w-5" />
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
<Combobox.Options className="absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{filteredTimeZones.map((timeZone) => (
|
||||
<Combobox.Option
|
||||
key={timeZone.value}
|
||||
className={styleMenuItem}
|
||||
value={timeZone}
|
||||
>
|
||||
{timeZone.label}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
<FloatingPortal>
|
||||
<Combobox.Options
|
||||
ref={floating}
|
||||
className="z-50 mt-1 max-h-72 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
style={{
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
}}
|
||||
>
|
||||
{filteredTimeZones.map((timeZone) => (
|
||||
<Combobox.Option
|
||||
key={timeZone.value}
|
||||
className={styleMenuItem}
|
||||
value={timeZone}
|
||||
>
|
||||
{timeZone.label}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</FloatingPortal>
|
||||
</div>
|
||||
</Combobox>
|
||||
);
|
||||
|
|
|
@ -55,23 +55,15 @@ export default async function handler(
|
|||
],
|
||||
},
|
||||
select: {
|
||||
urlId: true,
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc", // oldest first
|
||||
},
|
||||
})
|
||||
).map(({ urlId }) => urlId);
|
||||
).map(({ id }) => id);
|
||||
|
||||
if (pollIdsToDelete.length !== 0) {
|
||||
// Delete links
|
||||
await prisma.link.deleteMany({
|
||||
where: {
|
||||
pollId: {
|
||||
in: pollIdsToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Delete comments
|
||||
await prisma.comment.deleteMany({
|
||||
where: {
|
||||
|
@ -107,9 +99,13 @@ export default async function handler(
|
|||
},
|
||||
});
|
||||
|
||||
await prisma.$executeRaw`DELETE FROM options WHERE poll_id IN (${Prisma.join(
|
||||
pollIdsToDelete,
|
||||
)})`;
|
||||
|
||||
// Delete polls
|
||||
// Using execute raw to bypass soft delete middelware
|
||||
await prisma.$executeRaw`DELETE FROM polls WHERE url_id IN (${Prisma.join(
|
||||
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join(
|
||||
pollIdsToDelete,
|
||||
)})`;
|
||||
}
|
||||
|
|
|
@ -1,166 +0,0 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getQueryParam } from "@/utils/api-utils";
|
||||
import { LegacyPoll } from "@/utils/legacy-utils";
|
||||
import { getMongoClient } from "@/utils/mongodb-client";
|
||||
import { nanoid } from "@/utils/nanoid";
|
||||
import { GetPollApiResponse } from "@/utils/trpc/types";
|
||||
import { prisma } from "~/prisma/db";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<GetPollApiResponse>,
|
||||
) {
|
||||
const urlId = getQueryParam(req, "urlId");
|
||||
|
||||
const client = await getMongoClient();
|
||||
if (!client) {
|
||||
// This environment is not configured to retrieve legacy polls
|
||||
// from mongodb
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
const db = client.db("rallly-db");
|
||||
const collection = db.collection("events");
|
||||
|
||||
const legacyPoll = await collection.findOne<LegacyPoll>({ _id: urlId });
|
||||
|
||||
if (
|
||||
!legacyPoll ||
|
||||
!legacyPoll.dates ||
|
||||
legacyPoll.dates.length === 0 ||
|
||||
legacyPoll.isDeleted === true
|
||||
) {
|
||||
// return 404 if poll is missing or malformed or deleted
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
const newOptions: Array<{ id: string; value: string }> = [];
|
||||
|
||||
for (let i = 0; i < legacyPoll.dates.length; i++) {
|
||||
const date = legacyPoll.dates[i].toISOString();
|
||||
newOptions.push({
|
||||
id: await nanoid(),
|
||||
value: date,
|
||||
});
|
||||
}
|
||||
|
||||
const newParticipants = legacyPoll.participants?.map((legacyParticipant) => ({
|
||||
name: legacyParticipant.name,
|
||||
id: legacyParticipant._id.toString(),
|
||||
}));
|
||||
|
||||
const votes: Array<{
|
||||
optionId: string;
|
||||
participantId: string;
|
||||
type: VoteType;
|
||||
}> = [];
|
||||
|
||||
newParticipants?.forEach((p, i) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const legacyVotes = legacyPoll.participants![i].votes;
|
||||
legacyVotes?.forEach((v, j) => {
|
||||
votes.push({
|
||||
optionId: newOptions[j].id,
|
||||
participantId: p.id,
|
||||
type: v ? "yes" : "no",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
data: {
|
||||
legacy: true,
|
||||
closed: legacyPoll.isClosed,
|
||||
urlId: legacyPoll._id,
|
||||
title: legacyPoll.title,
|
||||
location: legacyPoll.location,
|
||||
description: legacyPoll.description,
|
||||
demo: legacyPoll.isExample,
|
||||
createdAt: new Date(legacyPoll.created),
|
||||
type: "date",
|
||||
authorName: legacyPoll.creator.name,
|
||||
verified: legacyPoll.creator.isVerified,
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
email: legacyPoll.creator.email,
|
||||
},
|
||||
create: {
|
||||
id: await nanoid(),
|
||||
email: legacyPoll.creator.email,
|
||||
name: legacyPoll.creator.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
notifications: legacyPoll.creator.allowNotifications,
|
||||
options: {
|
||||
createMany: {
|
||||
data: newOptions,
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
createMany: {
|
||||
data: newParticipants ?? [],
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
createMany: {
|
||||
data: votes,
|
||||
},
|
||||
},
|
||||
comments: {
|
||||
createMany: {
|
||||
data:
|
||||
legacyPoll.comments?.map((legacyComment) => ({
|
||||
content: legacyComment.content,
|
||||
createdAt: new Date(legacyComment.created),
|
||||
authorName: legacyComment.author.name,
|
||||
})) ?? [],
|
||||
},
|
||||
},
|
||||
links: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
role: "admin",
|
||||
urlId: legacyPoll._id,
|
||||
},
|
||||
{
|
||||
role: "participant",
|
||||
urlId: await nanoid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
},
|
||||
user: true,
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
...poll,
|
||||
role: "admin",
|
||||
urlId: poll.urlId,
|
||||
pollId: poll.urlId,
|
||||
});
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
import axios from "axios";
|
||||
import { GetServerSideProps, NextPage } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
|
||||
import FullPageLoader from "@/components/full-page-loader";
|
||||
|
@ -14,42 +12,30 @@ import { SessionProps, withSession } from "@/components/session";
|
|||
import { ParticipantsProvider } from "../components/participants-provider";
|
||||
import { withSessionSsr } from "../utils/auth";
|
||||
import { trpc } from "../utils/trpc";
|
||||
import { GetPollApiResponse } from "../utils/trpc/types";
|
||||
import Custom404 from "./404";
|
||||
|
||||
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
||||
|
||||
const PollPageLoader: NextPage<SessionProps> = () => {
|
||||
const { query } = useRouter();
|
||||
const { query, asPath } = useRouter();
|
||||
const { t } = useTranslation("app");
|
||||
const urlId = query.urlId as string;
|
||||
const plausible = usePlausible();
|
||||
const [notFound, setNotFound] = React.useState(false);
|
||||
const [legacyPoll, setLegacyPoll] = React.useState<GetPollApiResponse>();
|
||||
|
||||
const pollQuery = trpc.useQuery(["polls.get", { urlId }], {
|
||||
const admin = /^\/admin/.test(asPath);
|
||||
const pollQuery = trpc.useQuery(["polls.get", { urlId, admin }], {
|
||||
onError: () => {
|
||||
if (process.env.NEXT_PUBLIC_LEGACY_POLLS === "1") {
|
||||
axios
|
||||
.get<GetPollApiResponse>(`/api/legacy/${urlId}`)
|
||||
.then(({ data }) => {
|
||||
plausible("Converted legacy event");
|
||||
setLegacyPoll(data);
|
||||
})
|
||||
.catch(() => setNotFound(true));
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
setNotFound(true);
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const poll = pollQuery.data ?? legacyPoll;
|
||||
const poll = pollQuery.data;
|
||||
|
||||
if (poll) {
|
||||
return (
|
||||
<ParticipantsProvider pollId={poll.pollId}>
|
||||
<PollContextProvider value={poll}>
|
||||
<ParticipantsProvider pollId={poll.id}>
|
||||
<PollContextProvider poll={poll} urlId={urlId} admin={admin}>
|
||||
<PollPage />
|
||||
</PollContextProvider>
|
||||
</ParticipantsProvider>
|
||||
|
|
|
@ -7,12 +7,6 @@ import { absoluteUrl } from "../../utils/absolute-url";
|
|||
import { sendEmailTemplate } from "../../utils/api-utils";
|
||||
import { createToken } from "../../utils/auth";
|
||||
import { nanoid } from "../../utils/nanoid";
|
||||
import {
|
||||
createPollResponse,
|
||||
getDefaultPollInclude,
|
||||
getLink,
|
||||
getPollFromLink,
|
||||
} from "../../utils/queries";
|
||||
import { GetPollApiResponse } from "../../utils/trpc/types";
|
||||
import { createRouter } from "../createRouter";
|
||||
import { comments } from "./polls/comments";
|
||||
|
@ -20,6 +14,66 @@ import { demo } from "./polls/demo";
|
|||
import { participants } from "./polls/participants";
|
||||
import { verification } from "./polls/verification";
|
||||
|
||||
const defaultSelectFields: {
|
||||
id: true;
|
||||
timeZone: true;
|
||||
title: true;
|
||||
authorName: true;
|
||||
location: true;
|
||||
description: true;
|
||||
createdAt: true;
|
||||
participantUrlId: true;
|
||||
adminUrlId: true;
|
||||
verified: true;
|
||||
closed: true;
|
||||
legacy: true;
|
||||
demo: true;
|
||||
notifications: true;
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc";
|
||||
};
|
||||
};
|
||||
user: true;
|
||||
} = {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
title: true,
|
||||
authorName: true,
|
||||
location: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
participantUrlId: true,
|
||||
adminUrlId: true,
|
||||
verified: true,
|
||||
closed: true,
|
||||
legacy: true,
|
||||
notifications: true,
|
||||
demo: true,
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
};
|
||||
|
||||
const getPollIdFromAdminUrlId = async (urlId: string) => {
|
||||
const res = await prisma.poll.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: { adminUrlId: urlId },
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
return res.id;
|
||||
};
|
||||
|
||||
export const polls = createRouter()
|
||||
.merge("demo.", demo)
|
||||
.merge("participants.", participants)
|
||||
|
@ -39,12 +93,12 @@ export const polls = createRouter()
|
|||
options: z.string().array(),
|
||||
demo: z.boolean().optional(),
|
||||
}),
|
||||
resolve: async ({ ctx, input }) => {
|
||||
resolve: async ({ ctx, input }): Promise<{ urlId: string }> => {
|
||||
const adminUrlId = await nanoid();
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
data: {
|
||||
urlId: await nanoid(),
|
||||
id: await nanoid(),
|
||||
title: input.title,
|
||||
type: input.type,
|
||||
timeZone: input.timeZone,
|
||||
|
@ -55,6 +109,8 @@ export const polls = createRouter()
|
|||
verified:
|
||||
ctx.session.user?.isGuest === false &&
|
||||
ctx.session.user.email === input.user.email,
|
||||
adminUrlId,
|
||||
participantUrlId: await nanoid(),
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
|
@ -73,20 +129,6 @@ export const polls = createRouter()
|
|||
})),
|
||||
},
|
||||
},
|
||||
links: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
urlId: adminUrlId,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
urlId: await nanoid(),
|
||||
role: "participant",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -109,7 +151,7 @@ export const polls = createRouter()
|
|||
});
|
||||
} else {
|
||||
const verificationCode = await createToken({
|
||||
pollId: poll.urlId,
|
||||
pollId: poll.id,
|
||||
});
|
||||
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
|
||||
|
||||
|
@ -131,16 +173,38 @@ export const polls = createRouter()
|
|||
console.error(e);
|
||||
}
|
||||
|
||||
return { urlId: adminUrlId, authorName: poll.authorName };
|
||||
return { urlId: adminUrlId };
|
||||
},
|
||||
})
|
||||
.query("get", {
|
||||
input: z.object({
|
||||
urlId: z.string(),
|
||||
admin: z.boolean(),
|
||||
}),
|
||||
resolve: async ({ input }): Promise<GetPollApiResponse> => {
|
||||
const link = await getLink(input.urlId);
|
||||
return await getPollFromLink(link);
|
||||
resolve: async ({ input, ctx }): Promise<GetPollApiResponse> => {
|
||||
const poll = await prisma.poll.findFirst({
|
||||
select: defaultSelectFields,
|
||||
where: input.admin
|
||||
? {
|
||||
adminUrlId: input.urlId,
|
||||
}
|
||||
: {
|
||||
participantUrlId: input.urlId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// We want to keep the adminUrlId in if the user is view
|
||||
if (!input.admin && ctx.session.user?.id !== poll.user.id) {
|
||||
return { ...poll, admin: input.admin, adminUrlId: "" };
|
||||
}
|
||||
|
||||
return { ...poll, admin: input.admin };
|
||||
},
|
||||
})
|
||||
.mutation("update", {
|
||||
|
@ -156,13 +220,7 @@ export const polls = createRouter()
|
|||
closed: z.boolean().optional(),
|
||||
}),
|
||||
resolve: async ({ input }): Promise<GetPollApiResponse> => {
|
||||
const link = await getLink(input.urlId);
|
||||
|
||||
if (link.role !== "admin") {
|
||||
throw new Error("Use admin link to update poll");
|
||||
}
|
||||
|
||||
const { pollId } = link;
|
||||
const pollId = await getPollIdFromAdminUrlId(input.urlId);
|
||||
|
||||
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
|
||||
await prisma.option.deleteMany({
|
||||
|
@ -185,8 +243,9 @@ export const polls = createRouter()
|
|||
}
|
||||
|
||||
const poll = await prisma.poll.update({
|
||||
select: defaultSelectFields,
|
||||
where: {
|
||||
urlId: pollId,
|
||||
id: pollId,
|
||||
},
|
||||
data: {
|
||||
title: input.title,
|
||||
|
@ -196,10 +255,9 @@ export const polls = createRouter()
|
|||
notifications: input.notifications,
|
||||
closed: input.closed,
|
||||
},
|
||||
include: getDefaultPollInclude(link.role === "admin"),
|
||||
});
|
||||
|
||||
return createPollResponse(poll, link);
|
||||
return { ...poll, admin: true };
|
||||
},
|
||||
})
|
||||
.mutation("delete", {
|
||||
|
@ -207,15 +265,8 @@ export const polls = createRouter()
|
|||
urlId: z.string(),
|
||||
}),
|
||||
resolve: async ({ input: { urlId } }) => {
|
||||
const link = await getLink(urlId);
|
||||
if (link.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Tried to delete poll using participant url",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.poll.delete({ where: { urlId: link.pollId } });
|
||||
const pollId = await getPollIdFromAdminUrlId(urlId);
|
||||
await prisma.poll.delete({ where: { id: pollId } });
|
||||
},
|
||||
})
|
||||
.mutation("touch", {
|
||||
|
@ -225,7 +276,7 @@ export const polls = createRouter()
|
|||
resolve: async ({ input: { pollId } }) => {
|
||||
await prisma.poll.update({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
id: pollId,
|
||||
},
|
||||
data: {
|
||||
touchedAt: new Date(),
|
||||
|
|
|
@ -3,7 +3,6 @@ import addMinutes from "date-fns/addMinutes";
|
|||
|
||||
import { prisma } from "~/prisma/db";
|
||||
|
||||
import { absoluteUrl } from "../../../utils/absolute-url";
|
||||
import { nanoid } from "../../../utils/nanoid";
|
||||
import { createRouter } from "../../createRouter";
|
||||
|
||||
|
@ -72,18 +71,18 @@ export const demo = createRouter().mutation("create", {
|
|||
});
|
||||
}
|
||||
|
||||
const homePageUrl = absoluteUrl();
|
||||
|
||||
await prisma.poll.create({
|
||||
data: {
|
||||
urlId: await nanoid(),
|
||||
title: "Lunch Meeting Demo",
|
||||
id: await nanoid(),
|
||||
title: "Lunch Meeting",
|
||||
type: "date",
|
||||
location: "Starbucks, 901 New York Avenue",
|
||||
description: `This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to ${homePageUrl}/new to make a new poll.`,
|
||||
description: `Hey everyone, please choose the dates when you are available to meet for our monthly get together. Looking forward to see you all!`,
|
||||
authorName: "Johnny",
|
||||
verified: true,
|
||||
demo: true,
|
||||
adminUrlId,
|
||||
participantUrlId: await nanoid(),
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
|
@ -97,20 +96,6 @@ export const demo = createRouter().mutation("create", {
|
|||
data: options,
|
||||
},
|
||||
},
|
||||
links: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
role: "admin",
|
||||
urlId: adminUrlId,
|
||||
},
|
||||
{
|
||||
role: "participant",
|
||||
urlId: await nanoid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
createMany: {
|
||||
data: participants,
|
||||
|
|
|
@ -21,7 +21,7 @@ export const participants = createRouter()
|
|||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
createdAt: "asc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
|
@ -48,7 +48,7 @@ export const participants = createRouter()
|
|||
.mutation("add", {
|
||||
input: z.object({
|
||||
pollId: z.string(),
|
||||
name: z.string(),
|
||||
name: z.string().nonempty("Participant name is required"),
|
||||
votes: z
|
||||
.object({
|
||||
optionId: z.string(),
|
||||
|
|
|
@ -32,7 +32,7 @@ export const verification = createRouter()
|
|||
|
||||
const poll = await prisma.poll.update({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
id: pollId,
|
||||
},
|
||||
data: {
|
||||
verified: true,
|
||||
|
@ -63,11 +63,10 @@ export const verification = createRouter()
|
|||
resolve: async ({ input: { pollId, adminUrlId } }) => {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: pollId,
|
||||
id: pollId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -34,13 +34,9 @@ export const user = createRouter()
|
|||
closed: true,
|
||||
verified: true,
|
||||
createdAt: true,
|
||||
links: {
|
||||
where: {
|
||||
role: "admin",
|
||||
},
|
||||
},
|
||||
adminUrlId: true,
|
||||
},
|
||||
take: 5,
|
||||
take: 10,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Link } from "@prisma/client";
|
||||
import * as Eta from "eta";
|
||||
import { readFileSync } from "fs";
|
||||
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||
import { NextApiRequest } from "next";
|
||||
import path from "path";
|
||||
|
||||
import { prisma } from "~/prisma/db";
|
||||
|
@ -14,38 +13,6 @@ export const getQueryParam = (req: NextApiRequest, queryKey: string) => {
|
|||
return typeof value === "string" ? value : value[0];
|
||||
};
|
||||
|
||||
type ApiMiddleware<T, P extends Record<string, unknown>> = (
|
||||
ctx: {
|
||||
req: NextApiRequest;
|
||||
res: NextApiResponse<T>;
|
||||
} & P,
|
||||
) => Promise<void | NextApiResponse>;
|
||||
|
||||
/**
|
||||
* Gets the Link from `req.query.urlId` and passes it to handler
|
||||
* @param handler
|
||||
* @returns
|
||||
*/
|
||||
export const withLink = <T>(
|
||||
handler: ApiMiddleware<T, { link: Link }>,
|
||||
): NextApiHandler => {
|
||||
return async (req, res) => {
|
||||
const urlId = getQueryParam(req, "urlId");
|
||||
const link = await prisma.link.findUnique({ where: { urlId } });
|
||||
|
||||
if (!link) {
|
||||
res.status(404).json({
|
||||
status: 404,
|
||||
message: `Could not find link with urlId: ${urlId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await handler({ req, res, link });
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
type NotificationAction =
|
||||
| {
|
||||
type: "newParticipant";
|
||||
|
@ -62,8 +29,8 @@ export const sendNotification = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: { urlId: pollId },
|
||||
include: { user: true, links: true },
|
||||
where: { id: pollId },
|
||||
include: { user: true },
|
||||
});
|
||||
/**
|
||||
* poll needs to:
|
||||
|
@ -79,12 +46,8 @@ export const sendNotification = async (
|
|||
!poll.demo &&
|
||||
poll.notifications
|
||||
) {
|
||||
const adminLink = getAdminLink(poll.links);
|
||||
if (!adminLink) {
|
||||
throw new Error(`Missing admin link for poll: ${pollId}`);
|
||||
}
|
||||
const homePageUrl = absoluteUrl();
|
||||
const pollUrl = `${homePageUrl}/admin/${adminLink.urlId}`;
|
||||
const pollUrl = `${homePageUrl}/admin/${poll.adminUrlId}`;
|
||||
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
|
||||
|
||||
switch (action.type) {
|
||||
|
@ -127,9 +90,6 @@ export const sendNotification = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const getAdminLink = (links: Link[]) =>
|
||||
links.find((link) => link.role === "admin");
|
||||
|
||||
interface SendEmailTemplateParams {
|
||||
templateName: string;
|
||||
to: string;
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
|
||||
import { prisma } from "~/prisma/db";
|
||||
|
||||
import { getMongoClient } from "./mongodb-client";
|
||||
|
||||
export interface LegacyPoll {
|
||||
__private: {
|
||||
verificationCode: string;
|
||||
};
|
||||
_id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
isExample: boolean;
|
||||
isDeleted: boolean;
|
||||
isClosed: boolean;
|
||||
emails: string[];
|
||||
description: string;
|
||||
dates?: Date[];
|
||||
creator: {
|
||||
name: string;
|
||||
email: string;
|
||||
isVerified: boolean;
|
||||
allowNotifications: boolean;
|
||||
};
|
||||
created: Date;
|
||||
comments?: Array<{
|
||||
_id: ObjectId;
|
||||
author: {
|
||||
name: string;
|
||||
};
|
||||
content: string;
|
||||
created: Date;
|
||||
}>;
|
||||
participants?: Array<{
|
||||
_id: ObjectId;
|
||||
name: string;
|
||||
votes?: boolean[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export const resetDates = async (legacyPollId: string) => {
|
||||
const client = await getMongoClient();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const db = client.db("rallly-db");
|
||||
const collection = db.collection("events");
|
||||
|
||||
const legacyPoll = await collection.findOne<LegacyPoll>({
|
||||
_id: legacyPollId,
|
||||
});
|
||||
|
||||
if (!legacyPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingOptions = await prisma.option.findMany({
|
||||
where: { pollId: legacyPoll._id },
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < existingOptions.length; i++) {
|
||||
const existingOption = existingOptions[i];
|
||||
if (existingOption.value.indexOf("T") === -1) {
|
||||
const legacyOption = legacyPoll.dates?.find(
|
||||
(date) => date.toISOString().substring(0, 10) === existingOption.value,
|
||||
);
|
||||
if (legacyOption) {
|
||||
promises.push(
|
||||
prisma.option.update({
|
||||
where: { id: existingOption.id },
|
||||
data: {
|
||||
value: legacyOption.toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$transaction(promises);
|
||||
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: legacyPoll._id,
|
||||
},
|
||||
include: {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
},
|
||||
user: true,
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
return poll;
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
// Import the dependency.
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
declare global {
|
||||
// allow global `var` declarations
|
||||
// eslint-disable-next-line no-var
|
||||
var _mongoClientPromise: Promise<MongoClient>;
|
||||
}
|
||||
|
||||
const uri = process.env.LEGACY_MONGODB_URI;
|
||||
|
||||
let client;
|
||||
let clientPromise;
|
||||
|
||||
export const getMongoClient = async () => {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (global._mongoClientPromise) {
|
||||
return global._mongoClientPromise;
|
||||
}
|
||||
|
||||
client = new MongoClient(uri);
|
||||
clientPromise = client.connect();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
global._mongoClientPromise = clientPromise;
|
||||
}
|
||||
|
||||
return clientPromise;
|
||||
};
|
|
@ -1,81 +0,0 @@
|
|||
import { Link, Option, Poll, User } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { prisma } from "~/prisma/db";
|
||||
|
||||
import { GetPollApiResponse } from "./trpc/types";
|
||||
|
||||
export const getDefaultPollInclude = <V extends boolean>(
|
||||
includeLinks: V,
|
||||
): {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc";
|
||||
};
|
||||
};
|
||||
user: true;
|
||||
links: V;
|
||||
} => {
|
||||
return {
|
||||
options: {
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
links: includeLinks,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLink = async (urlId: string) => {
|
||||
const link = await prisma.link.findUnique({
|
||||
where: {
|
||||
urlId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Link not found with id: ${urlId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
export const getPollFromLink = async (
|
||||
link: Link,
|
||||
): Promise<GetPollApiResponse> => {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: link.pollId,
|
||||
},
|
||||
include: getDefaultPollInclude(link.role === "admin"),
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Poll not found with id: ${link.pollId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return createPollResponse(poll, link);
|
||||
};
|
||||
|
||||
export const createPollResponse = (
|
||||
poll: Poll & {
|
||||
options: Option[];
|
||||
user: User;
|
||||
links: Link[];
|
||||
},
|
||||
link: Link,
|
||||
): GetPollApiResponse => {
|
||||
return {
|
||||
...poll,
|
||||
role: link.role,
|
||||
urlId: link.urlId,
|
||||
pollId: poll.urlId,
|
||||
};
|
||||
};
|
|
@ -1,9 +1,21 @@
|
|||
import { Link, Option, Poll, Role, User } from "@prisma/client";
|
||||
import { Option, User } from "@prisma/client";
|
||||
|
||||
export interface GetPollApiResponse extends Poll {
|
||||
export type GetPollApiResponse = {
|
||||
id: string;
|
||||
title: string;
|
||||
authorName: string;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
options: Option[];
|
||||
user: User;
|
||||
role: Role;
|
||||
links: Array<Link>;
|
||||
pollId: string;
|
||||
}
|
||||
timeZone: string | null;
|
||||
adminUrlId: string;
|
||||
participantUrlId: string;
|
||||
verified: boolean;
|
||||
closed: boolean;
|
||||
admin: boolean;
|
||||
legacy: boolean;
|
||||
demo: boolean;
|
||||
notifications: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
|
22
style.css
22
style.css
|
@ -7,7 +7,7 @@
|
|||
body,
|
||||
#__next {
|
||||
height: 100%;
|
||||
@apply bg-slate-50;
|
||||
@apply overflow-x-hidden bg-slate-50;
|
||||
}
|
||||
body {
|
||||
@apply bg-slate-50 text-base text-slate-600;
|
||||
|
@ -32,13 +32,13 @@
|
|||
@apply outline-none;
|
||||
}
|
||||
a {
|
||||
@apply text-primary-500 hover:text-primary-400 focus-visible:ring-primary-500 rounded-sm font-medium outline-none hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
|
||||
@apply rounded-sm font-medium text-primary-500 outline-none hover:text-primary-400 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
label {
|
||||
@apply mb-1 block text-sm text-slate-800;
|
||||
}
|
||||
button {
|
||||
@apply focus-visible:ring-primary-500 cursor-default outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
|
||||
@apply cursor-default outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
|
||||
#floating-ui-root {
|
||||
|
@ -51,7 +51,7 @@
|
|||
@apply mb-4;
|
||||
}
|
||||
.input {
|
||||
@apply focus:border-primary-500 focus:ring-primary-500 appearance-none rounded-md border border-gray-200 px-2 py-1 text-slate-700 shadow-sm placeholder:text-slate-400 focus:ring-1;
|
||||
@apply appearance-none rounded-md border border-gray-200 px-2 py-1 text-slate-700 shadow-sm placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
|
||||
}
|
||||
input.input {
|
||||
@apply h-9;
|
||||
|
@ -63,17 +63,17 @@
|
|||
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
|
||||
}
|
||||
.checkbox {
|
||||
@apply text-primary-500 focus:ring-primary-500 h-4 w-4 rounded border-slate-300 shadow-sm;
|
||||
@apply h-4 w-4 rounded border-slate-300 text-primary-500 shadow-sm focus:ring-primary-500;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all sm:active:scale-95;
|
||||
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all;
|
||||
}
|
||||
a.btn {
|
||||
@apply cursor-pointer hover:no-underline;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
@apply btn hover:bg-primary-50/10 bg-white text-slate-700 active:bg-slate-100;
|
||||
@apply btn bg-white text-slate-700 hover:bg-slate-100/10 active:bg-slate-500/10;
|
||||
}
|
||||
|
||||
a.btn-default {
|
||||
|
@ -83,7 +83,7 @@
|
|||
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
|
||||
}
|
||||
.btn-link {
|
||||
@apply text-primary-500 inline-flex items-center underline;
|
||||
@apply inline-flex items-center text-primary-500 underline;
|
||||
}
|
||||
.btn.btn-disabled {
|
||||
text-shadow: none;
|
||||
|
@ -91,7 +91,7 @@
|
|||
}
|
||||
.btn-primary {
|
||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
||||
@apply btn border-primary-600 bg-primary-500 focus-visible:ring-primary-500 active:bg-primary-600 text-white hover:bg-opacity-90;
|
||||
@apply btn border-primary-600 bg-primary-500 text-white hover:bg-opacity-90 focus-visible:ring-primary-500 active:bg-primary-600;
|
||||
}
|
||||
|
||||
a.btn-primary {
|
||||
|
@ -127,13 +127,13 @@
|
|||
}
|
||||
|
||||
.card {
|
||||
@apply border-t border-b bg-white p-6 shadow-sm sm:rounded-lg sm:border-l sm:border-r;
|
||||
@apply border-y bg-white p-4 shadow-sm md:rounded-lg md:border;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.heading {
|
||||
@apply text-primary-500 text-xl;
|
||||
@apply text-xl text-primary-500;
|
||||
}
|
||||
.subheading {
|
||||
@apply mb-16 text-4xl font-bold text-slate-800;
|
||||
|
|
|
@ -5,7 +5,7 @@ test("should show warning when deleting options with votes in them", async ({
|
|||
}) => {
|
||||
await page.goto("/demo");
|
||||
|
||||
await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible();
|
||||
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
|
||||
|
||||
await page.click("text='Manage'");
|
||||
await page.click("text='Edit options'");
|
||||
|
|
|
@ -16,61 +16,75 @@ test.beforeAll(async ({ request, baseURL }) => {
|
|||
// Active Poll
|
||||
{
|
||||
title: "Active Poll",
|
||||
urlId: "active-poll",
|
||||
id: "active-poll",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
participantUrlId: "p1",
|
||||
adminUrlId: "a1",
|
||||
},
|
||||
// Poll that has been deleted 6 days ago
|
||||
{
|
||||
title: "Deleted poll",
|
||||
urlId: "deleted-poll-6d",
|
||||
id: "deleted-poll-6d",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
deleted: true,
|
||||
deletedAt: addDays(new Date(), -6),
|
||||
participantUrlId: "p2",
|
||||
adminUrlId: "a2",
|
||||
},
|
||||
// Poll that has been deleted 7 days ago
|
||||
{
|
||||
title: "Deleted poll 7d",
|
||||
urlId: "deleted-poll-7d",
|
||||
id: "deleted-poll-7d",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
deleted: true,
|
||||
deletedAt: addDays(new Date(), -7),
|
||||
participantUrlId: "p3",
|
||||
adminUrlId: "a3",
|
||||
},
|
||||
// Poll that has been inactive for 29 days
|
||||
{
|
||||
title: "Still active",
|
||||
urlId: "still-active-poll",
|
||||
id: "still-active-poll",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
touchedAt: addDays(new Date(), -29),
|
||||
participantUrlId: "p4",
|
||||
adminUrlId: "a4",
|
||||
},
|
||||
// Poll that has been inactive for 30 days
|
||||
{
|
||||
title: "Inactive poll",
|
||||
urlId: "inactive-poll",
|
||||
id: "inactive-poll",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
touchedAt: addDays(new Date(), -30),
|
||||
participantUrlId: "p5",
|
||||
adminUrlId: "a5",
|
||||
},
|
||||
// Demo poll
|
||||
{
|
||||
demo: true,
|
||||
title: "Demo poll",
|
||||
urlId: "demo-poll-new",
|
||||
id: "demo-poll-new",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
createdAt: new Date(),
|
||||
participantUrlId: "p6",
|
||||
adminUrlId: "a6",
|
||||
},
|
||||
// Old demo poll
|
||||
{
|
||||
demo: true,
|
||||
title: "Demo poll",
|
||||
urlId: "demo-poll-old",
|
||||
id: "demo-poll-old",
|
||||
type: "date",
|
||||
userId: "user1",
|
||||
createdAt: addDays(new Date(), -2),
|
||||
participantUrlId: "p7",
|
||||
adminUrlId: "a7",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -138,7 +152,7 @@ test.beforeAll(async ({ request, baseURL }) => {
|
|||
test("should keep active polls", async () => {
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: "active-poll",
|
||||
id: "active-poll",
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -150,7 +164,7 @@ test("should keep active polls", async () => {
|
|||
test("should keep polls that have been soft deleted for less than 7 days", async () => {
|
||||
const deletedPoll6d = await prisma.poll.findFirst({
|
||||
where: {
|
||||
urlId: "deleted-poll-6d",
|
||||
id: "deleted-poll-6d",
|
||||
deleted: true,
|
||||
},
|
||||
});
|
||||
|
@ -162,7 +176,7 @@ test("should keep polls that have been soft deleted for less than 7 days", async
|
|||
test("should hard delete polls that have been soft deleted for 7 days", async () => {
|
||||
const deletedPoll7d = await prisma.poll.findFirst({
|
||||
where: {
|
||||
urlId: "deleted-poll-7d",
|
||||
id: "deleted-poll-7d",
|
||||
deleted: true,
|
||||
},
|
||||
});
|
||||
|
@ -197,7 +211,7 @@ test("should hard delete polls that have been soft deleted for 7 days", async ()
|
|||
test("should keep polls that are still active", async () => {
|
||||
const stillActivePoll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
urlId: "still-active-poll",
|
||||
id: "still-active-poll",
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -208,7 +222,7 @@ test("should keep polls that are still active", async () => {
|
|||
test("should soft delete polls that are inactive", async () => {
|
||||
const inactivePoll = await prisma.poll.findFirst({
|
||||
where: {
|
||||
urlId: "inactive-poll",
|
||||
id: "inactive-poll",
|
||||
deleted: true,
|
||||
},
|
||||
});
|
||||
|
@ -221,7 +235,7 @@ test("should soft delete polls that are inactive", async () => {
|
|||
test("should keep new demo poll", async () => {
|
||||
const demoPoll = await prisma.poll.findFirst({
|
||||
where: {
|
||||
urlId: "demo-poll-new",
|
||||
id: "demo-poll-new",
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -231,7 +245,7 @@ test("should keep new demo poll", async () => {
|
|||
test("should delete old demo poll", async () => {
|
||||
const oldDemoPoll = await prisma.poll.findFirst({
|
||||
where: {
|
||||
urlId: "demo-poll-old",
|
||||
id: "demo-poll-old",
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -240,7 +254,7 @@ test("should delete old demo poll", async () => {
|
|||
|
||||
// Teardown
|
||||
test.afterAll(async () => {
|
||||
await prisma.$executeRaw`DELETE FROM polls WHERE url_id IN (${Prisma.join([
|
||||
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join([
|
||||
"active-poll",
|
||||
"deleted-poll-6d",
|
||||
"deleted-poll-7d",
|
||||
|
|
|
@ -4,7 +4,7 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
|
|||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/demo");
|
||||
|
||||
await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible();
|
||||
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
|
||||
|
||||
await page.click("text='New'");
|
||||
await page.click("data-testid=poll-option >> nth=0");
|
||||
|
|
|
@ -9,7 +9,7 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
|
|||
|
||||
await page.goto("/demo");
|
||||
|
||||
await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible();
|
||||
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
|
||||
|
||||
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
|
||||
|
@ -20,7 +20,7 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
|
|||
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"),
|
||||
page.locator("data-testid=participant-row >> nth=4").locator("text=You"),
|
||||
).toBeVisible();
|
||||
await page.type(
|
||||
"[placeholder='Thanks for the invite!']",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue