Updated links model and poll page (#206)

* Improved sharing
* Updated desktop poll
This commit is contained in:
Luke Vella 2022-06-27 15:22:23 +01:00 committed by GitHub
parent c4cbf2f6bb
commit 2ead375b42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 955 additions and 1848 deletions

View file

@ -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",

View file

@ -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");

View file

@ -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");

View file

@ -29,7 +29,7 @@ enum PollType {
}
model Poll {
urlId String @id @unique @map("url_id")
id String @id @unique @map("id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deadline DateTime?
@ -47,42 +47,25 @@ model Poll {
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")
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])

View file

@ -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.",

View file

@ -1,5 +1,5 @@
{
"getStarted": "Get started",
"viewDemo": "View demo",
"viewDemo": "Live demo",
"footerCredit": "Self-funded and built by <a>@imlukevella</a>"
}

View file

@ -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",

View file

@ -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`);
},
});

View file

@ -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,7 +131,6 @@ const Discussion: React.VoidFunctionComponent = () => {
)}
</span>
</div>
{canDelete ? (
<Dropdown
placement="bottom-start"
trigger={<CompactButton icon={DotsHorizontal} />}
@ -143,6 +138,7 @@ const Discussion: React.VoidFunctionComponent = () => {
<DropdownItem
icon={Trash}
label="Delete comment"
disabled={!canDelete}
onClick={() => {
deleteComment.mutate({
commentId: comment.id,
@ -151,7 +147,6 @@ const Discussion: React.VoidFunctionComponent = () => {
}}
/>
</Dropdown>
) : null}
</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>

View file

@ -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";

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="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

View file

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

View file

@ -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"
>
{preventWidows(poll.title)}
</h1>
{poll.role === "admin" ? (
<div className="mb-4 flex space-x-2 md:mb-2">
{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"
}
placement={isWideScreen ? "bottom-end" : "bottom-start"}
/>
<div>
<Popover
trigger={
<Button type="primary" icon={<Share />}>
<Button
type="primary"
icon={<Share />}
onClick={() => {
setSharingVisible((value) => !value);
}}
>
Share
</Button>
}
placement={isWideScreen ? "bottom-end" : undefined}
</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"
>
<Sharing links={poll.links} />
</Popover>
<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>
) : 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>
<a href={`/admin/${poll.adminUrlId}`} className="btn-default">
Go to admin &rarr;
</a>
</div>
) : null}
{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>
<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>
</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">
<div className="border-primary whitespace-pre-line lg:text-lg">
<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 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>
{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)
</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>
<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 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 />
</div>
) : null}
</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>

View file

@ -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,17 +103,17 @@ 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 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")}
@ -133,21 +124,10 @@ const Poll: React.VoidFunctionComponent = () => {
className="grow"
/>
</div>
</div>
) : null}
<div className="flex shrink-0">
<Button
type="primary"
disabled={shouldShowNewParticipantForm || poll.closed}
icon={<PlusCircle />}
onClick={() => {
setShouldShowNewParticipantForm(true);
}}
>
New Participant
</Button>
</div>
</div>
<div className="flex">
<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,44 +166,22 @@ const Poll: React.VoidFunctionComponent = () => {
className="text-xs"
rounded={true}
onClick={() => {
setDidUsePagination(true);
goToNextPage();
}}
>
{didUsePagination ? (
<ArrowRight className="h-4 w-4" />
) : (
`+${numberOfInvisibleColumns} more…`
)}
</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.length > 0 ? (
<div
className="min-h-0 overflow-y-auto py-2"
ref={participantListContainerRef}
>
{participants.map((participant, i) => {
return (
<ParticipantRow
@ -231,12 +189,32 @@ const Poll: React.VoidFunctionComponent = () => {
participant={participant}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
setEditingParticipantId(isEditing ? participant.id : null);
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>

View file

@ -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,6 +160,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
/>
<div className="flex items-center space-x-2 px-2 transition-all">
{scrollPosition >= maxScrollPosition ? (
<Button
htmlType="submit"
icon={<Check />}
@ -169,7 +170,18 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
>
Save
</Button>
<CompactButton onClick={onCancel} icon={X} />
) : null}
{scrollPosition < maxScrollPosition ? (
<Button
onClick={(e) => {
e.stopPropagation();
goToNextPage();
}}
>
Next &rarr;
</Button>
) : null}
{onCancel ? <CompactButton onClick={onCancel} icon={X} /> : null}
</div>
</form>
);

View file

@ -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={() => {

View file

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

View file

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

View file

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

View file

@ -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,37 +60,33 @@ 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 [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,
const [isEditing, setIsEditing] = React.useState(
!userAlreadyVoted && !poll.closed && !poll.admin,
);
}
};
setState();
window.addEventListener("scroll", setState, true);
return () => {
window.removeEventListener("scroll", setState, true);
};
}, []);
const formRef = React.useRef<HTMLFormElement>(null);
const { t } = useTranslation("app");
@ -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,6 +122,7 @@ 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">
{!isEditing ? (
<Listbox
value={selectedParticipantId}
onChange={(participantId) => {
@ -199,6 +182,24 @@ const MobilePoll: React.VoidFunctionComponent = () => {
</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>
)}
{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,29 +311,10 @@ 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>
<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}
@ -362,7 +322,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
Save
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>

View file

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

View file

@ -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");
},
});

View file

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

View file

@ -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 />,
}}
/>
&nbsp;
<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" ? (
{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">Legacy</Badge>
<Badge color="amber" className="ml-1">
Legacy
</Badge>
</Tooltip>
) : null}
{poll.demo ? (
<Tooltip content={<Trans t={t} i18nKey="demoPollNotice" />}>
<Badge color="blue">Demo</Badge>
<Badge color="blue" className="ml-1">
Demo
</Badge>
</Tooltip>
) : null}
</span>
</div>
<span className="hidden md:inline">&nbsp;&bull;&nbsp;</span>
<span className="whitespace-nowrap">
{formatRelative(new Date(poll.createdAt), new Date(), {
{formatRelative(poll.createdAt, new Date(), {
locale,
})}
</span>

View 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>
);
};

View file

@ -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,
});
},

View file

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

View file

@ -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} />
<Button
className="w-24 shrink-0"
disabled={didCopy}
onClick={() => {
copyToClipboard(pollUrl);
setDidCopy(true);
setTimeout(() => setDidCopy(false), 1000);
plausible("Copied share link", {
props: {
role,
<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
disabled={didCopy}
type="primary"
onClick={() => {
copyToClipboard(participantUrl);
setDidCopy(true);
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>
);
};

View file

@ -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">&bull;</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">&bull;</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>

View file

@ -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, {
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}
/>
);

View file

@ -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,7 +210,16 @@ 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">
<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}
@ -193,6 +230,7 @@ const TimeZonePicker: React.VoidFunctionComponent<{
</Combobox.Option>
))}
</Combobox.Options>
</FloatingPortal>
</div>
</Combobox>
);

View file

@ -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,
)})`;
}

View file

@ -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,
});
}

View file

@ -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);
}
},
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>

View file

@ -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(),

View file

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

View file

@ -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(),

View file

@ -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,
},
});

View file

@ -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",
},

View file

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

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -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;
};

View file

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

View file

@ -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'");

View file

@ -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",

View file

@ -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");

View file

@ -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!']",

664
yarn.lock

File diff suppressed because it is too large Load diff