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": "^2.28.0",
|
||||||
"date-fns-tz": "^1.2.2",
|
"date-fns-tz": "^1.2.2",
|
||||||
"eta": "^1.12.3",
|
"eta": "^1.12.3",
|
||||||
"framer-motion": "^6.2.9",
|
"framer-motion": "^6.3.11",
|
||||||
"iron-session": "^6.1.3",
|
"iron-session": "^6.1.3",
|
||||||
"jose": "^4.5.1",
|
"jose": "^4.5.1",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mongodb": "^4.5.0",
|
|
||||||
"nanoid": "^3.1.30",
|
"nanoid": "^3.1.30",
|
||||||
"next": "^12.1.4",
|
"next": "^12.1.4",
|
||||||
"next-i18next": "^10.5.0",
|
"next-i18next": "^10.5.0",
|
||||||
|
@ -60,7 +59,7 @@
|
||||||
"zod": "^3.16.0"
|
"zod": "^3.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.20.1",
|
"@playwright/test": "^1.22.2",
|
||||||
"@types/lodash": "^4.14.178",
|
"@types/lodash": "^4.14.178",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/react": "^17.0.5",
|
"@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 {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
referentialIntegrity = "prisma"
|
referentialIntegrity = "prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["referentialIntegrity"]
|
previewFeatures = ["referentialIntegrity"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,60 +29,43 @@ enum PollType {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Poll {
|
model Poll {
|
||||||
urlId String @id @unique @map("url_id")
|
id String @id @unique @map("id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deadline DateTime?
|
deadline DateTime?
|
||||||
title String
|
title String
|
||||||
type PollType
|
type PollType
|
||||||
description String?
|
description String?
|
||||||
location String?
|
location String?
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
timeZone String? @map("time_zone")
|
timeZone String? @map("time_zone")
|
||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
options Option[]
|
options Option[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
authorName String @default("") @map("author_name")
|
authorName String @default("") @map("author_name")
|
||||||
demo Boolean @default(false)
|
demo Boolean @default(false)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
links Link[]
|
legacy Boolean @default(false)
|
||||||
legacy Boolean @default(false)
|
closed Boolean @default(false)
|
||||||
closed Boolean @default(false)
|
notifications Boolean @default(false)
|
||||||
notifications Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
deleted Boolean @default(false)
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
touchedAt DateTime @default(now()) @map("touched_at")
|
||||||
touchedAt DateTime @default(now()) @map("touched_at")
|
participantUrlId String @unique @map("participant_url_id")
|
||||||
|
adminUrlId String @unique @map("admin_url_id")
|
||||||
|
|
||||||
@@map("polls")
|
@@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 {
|
model Participant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
guestId String? @map("guest_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")
|
pollId String @map("poll_id")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
@ -96,7 +79,7 @@ model Option {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
value String
|
value String
|
||||||
pollId String @map("poll_id")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
|
@ -118,7 +101,7 @@ model Vote {
|
||||||
participantId String @map("participant_id")
|
participantId String @map("participant_id")
|
||||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||||
optionId String @map("option_id")
|
optionId String @map("option_id")
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
poll Poll @relation(fields: [pollId], references: [id])
|
||||||
pollId String @map("poll_id")
|
pollId String @map("poll_id")
|
||||||
type VoteType @default(yes)
|
type VoteType @default(yes)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
@ -130,7 +113,7 @@ model Vote {
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
content String
|
content String
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId])
|
poll Poll @relation(fields: [pollId], references: [id])
|
||||||
pollId String @map("poll_id")
|
pollId String @map("poll_id")
|
||||||
authorName String @map("author_name")
|
authorName String @map("author_name")
|
||||||
user User? @relation(fields: [userId], references: [id])
|
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.",
|
"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.",
|
"errorCreate": "Uh oh! There was a problem creating your poll. The error has been logged and we'll try to fix it.",
|
||||||
"share": "Share",
|
"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",
|
"requiredNameError": "Name is required",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"change": "Change",
|
"change": "Change",
|
||||||
|
@ -38,9 +38,7 @@
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"loadingParticipants": "Loading participants…",
|
"loadingParticipants": "Loading participants…",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminDescription": "Full access to edit this poll.",
|
|
||||||
"participant": "Participant",
|
"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.",
|
"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.",
|
"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.",
|
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted.",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"getStarted": "Get started",
|
"getStarted": "Get started",
|
||||||
"viewDemo": "View demo",
|
"viewDemo": "Live demo",
|
||||||
"footerCredit": "Self-funded and built by <a>@imlukevella</a>"
|
"footerCredit": "Self-funded and built by <a>@imlukevella</a>"
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ const Badge: React.VoidFunctionComponent<{
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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-slate-200 text-slate-500": color === "gray",
|
||||||
"bg-amber-100 text-amber-500": color === "amber",
|
"bg-amber-100 text-amber-500": color === "amber",
|
||||||
|
|
|
@ -95,7 +95,7 @@ const Page: NextPage<CreatePollPageProps> = ({
|
||||||
const plausible = usePlausible();
|
const plausible = usePlausible();
|
||||||
|
|
||||||
const createPoll = trpc.useMutation(["polls.create"], {
|
const createPoll = trpc.useMutation(["polls.create"], {
|
||||||
onSuccess: (poll) => {
|
onSuccess: (res) => {
|
||||||
setIsRedirecting(true);
|
setIsRedirecting(true);
|
||||||
plausible("Created poll", {
|
plausible("Created poll", {
|
||||||
props: {
|
props: {
|
||||||
|
@ -104,7 +104,7 @@ const Page: NextPage<CreatePollPageProps> = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setPersistedFormData(initialNewEventData);
|
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 Discussion: React.VoidFunctionComponent = () => {
|
||||||
const { locale } = usePreferences();
|
const { locale } = usePreferences();
|
||||||
const queryClient = trpc.useContext();
|
const queryClient = trpc.useContext();
|
||||||
const {
|
const { poll } = usePoll();
|
||||||
poll: { pollId },
|
|
||||||
} = usePoll();
|
const pollId = poll.id;
|
||||||
|
|
||||||
const { data: comments } = trpc.useQuery(
|
const { data: comments } = trpc.useQuery(
|
||||||
["polls.comments.list", { pollId }],
|
["polls.comments.list", { pollId }],
|
||||||
|
@ -53,8 +53,6 @@ const Discussion: React.VoidFunctionComponent = () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { poll } = usePoll();
|
|
||||||
|
|
||||||
const deleteComment = trpc.useMutation("polls.comments.delete", {
|
const deleteComment = trpc.useMutation("polls.comments.delete", {
|
||||||
onMutate: ({ commentId }) => {
|
onMutate: ({ commentId }) => {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
|
@ -96,9 +94,7 @@ const Discussion: React.VoidFunctionComponent = () => {
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{comments.map((comment) => {
|
{comments.map((comment) => {
|
||||||
const canDelete =
|
const canDelete =
|
||||||
poll.role === "admin" ||
|
poll.admin || session.ownsObject(comment) || isUnclaimed(comment);
|
||||||
session.ownsObject(comment) ||
|
|
||||||
isUnclaimed(comment);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -135,23 +131,22 @@ const Discussion: React.VoidFunctionComponent = () => {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canDelete ? (
|
<Dropdown
|
||||||
<Dropdown
|
placement="bottom-start"
|
||||||
placement="bottom-start"
|
trigger={<CompactButton icon={DotsHorizontal} />}
|
||||||
trigger={<CompactButton icon={DotsHorizontal} />}
|
>
|
||||||
>
|
<DropdownItem
|
||||||
<DropdownItem
|
icon={Trash}
|
||||||
icon={Trash}
|
label="Delete comment"
|
||||||
label="Delete comment"
|
disabled={!canDelete}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteComment.mutate({
|
deleteComment.mutate({
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
pollId,
|
pollId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit whitespace-pre-wrap">
|
<div className="w-fit whitespace-pre-wrap">
|
||||||
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
|
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
|
||||||
|
@ -187,11 +182,7 @@ const Discussion: React.VoidFunctionComponent = () => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button htmlType="submit" loading={formState.isSubmitting}>
|
||||||
htmlType="submit"
|
|
||||||
loading={formState.isSubmitting}
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
Comment
|
Comment
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -87,6 +87,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
||||||
color={participant.color}
|
color={participant.color}
|
||||||
sidebarWidth={sidebarWidth}
|
sidebarWidth={sidebarWidth}
|
||||||
columnWidth={columnWidth}
|
columnWidth={columnWidth}
|
||||||
|
participantId={`participant${i}`}
|
||||||
name={participant.name}
|
name={participant.name}
|
||||||
votes={options.map((_, i) => {
|
votes={options.map((_, i) => {
|
||||||
return participant.votes.some((vote) => vote === i) ? "yes" : "no";
|
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 = {
|
type PollContextValue = {
|
||||||
userAlreadyVoted: boolean;
|
userAlreadyVoted: boolean;
|
||||||
poll: GetPollApiResponse;
|
poll: GetPollApiResponse;
|
||||||
|
urlId: string;
|
||||||
|
admin: boolean;
|
||||||
targetTimeZone: string;
|
targetTimeZone: string;
|
||||||
|
participantUrl: string;
|
||||||
setTargetTimeZone: (timeZone: string) => void;
|
setTargetTimeZone: (timeZone: string) => void;
|
||||||
pollType: "date" | "timeSlot";
|
pollType: "date" | "timeSlot";
|
||||||
highScore: number;
|
highScore: number;
|
||||||
|
@ -49,9 +52,11 @@ export const usePoll = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PollContextProvider: React.VoidFunctionComponent<{
|
export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
value: GetPollApiResponse;
|
poll: GetPollApiResponse;
|
||||||
|
urlId: string;
|
||||||
|
admin: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}> = ({ value: poll, children }) => {
|
}> = ({ poll, urlId, admin, children }) => {
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
const [isDeleted, setDeleted] = React.useState(false);
|
const [isDeleted, setDeleted] = React.useState(false);
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
@ -129,10 +134,17 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { participantUrlId } = poll;
|
||||||
|
|
||||||
|
const participantUrl = `${window.location.origin}/p/${participantUrlId}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
optionIds,
|
optionIds,
|
||||||
userAlreadyVoted,
|
userAlreadyVoted,
|
||||||
poll,
|
poll,
|
||||||
|
urlId,
|
||||||
|
admin,
|
||||||
|
participantUrl,
|
||||||
getParticipantById: (participantId) => {
|
getParticipantById: (participantId) => {
|
||||||
return participantById[participantId];
|
return participantById[participantId];
|
||||||
},
|
},
|
||||||
|
@ -152,7 +164,17 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
isDeleted,
|
isDeleted,
|
||||||
setDeleted,
|
setDeleted,
|
||||||
};
|
};
|
||||||
}, [getScore, isDeleted, locale, participants, poll, targetTimeZone, user]);
|
}, [
|
||||||
|
admin,
|
||||||
|
getScore,
|
||||||
|
isDeleted,
|
||||||
|
locale,
|
||||||
|
participants,
|
||||||
|
poll,
|
||||||
|
targetTimeZone,
|
||||||
|
urlId,
|
||||||
|
user,
|
||||||
|
]);
|
||||||
|
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
import { usePlausible } from "next-plausible";
|
import { usePlausible } from "next-plausible";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useMount } from "react-use";
|
import { useMount } from "react-use";
|
||||||
|
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import LocationMarker from "@/components/icons/location-marker.svg";
|
|
||||||
import LockClosed from "@/components/icons/lock-closed.svg";
|
import LockClosed from "@/components/icons/lock-closed.svg";
|
||||||
import Share from "@/components/icons/share.svg";
|
import Share from "@/components/icons/share.svg";
|
||||||
import { preventWidows } from "@/utils/prevent-widows";
|
import { preventWidows } from "@/utils/prevent-widows";
|
||||||
|
@ -19,11 +20,11 @@ import { useUpdatePollMutation } from "./poll/mutations";
|
||||||
import NotificationsToggle from "./poll/notifications-toggle";
|
import NotificationsToggle from "./poll/notifications-toggle";
|
||||||
import PollSubheader from "./poll/poll-subheader";
|
import PollSubheader from "./poll/poll-subheader";
|
||||||
import TruncatedLinkify from "./poll/truncated-linkify";
|
import TruncatedLinkify from "./poll/truncated-linkify";
|
||||||
|
import { UnverifiedPollNotice } from "./poll/unverified-poll-notice";
|
||||||
import { useTouchBeacon } from "./poll/use-touch-beacon";
|
import { useTouchBeacon } from "./poll/use-touch-beacon";
|
||||||
import { UserAvatarProvider } from "./poll/user-avatar";
|
import { UserAvatarProvider } from "./poll/user-avatar";
|
||||||
import VoteIcon from "./poll/vote-icon";
|
import VoteIcon from "./poll/vote-icon";
|
||||||
import { usePoll } from "./poll-context";
|
import { usePoll } from "./poll-context";
|
||||||
import Popover from "./popover";
|
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
import Sharing from "./sharing";
|
import Sharing from "./sharing";
|
||||||
import StandardLayout from "./standard-layout";
|
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 MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
||||||
|
|
||||||
const PollPage: NextPage = () => {
|
const PollPage: NextPage = () => {
|
||||||
const { poll } = usePoll();
|
const { poll, urlId, admin } = usePoll();
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useTouchBeacon(poll.pollId);
|
useTouchBeacon(poll.id);
|
||||||
|
|
||||||
useMount(() => {
|
const { t } = useTranslation("app");
|
||||||
const path = poll.role === "admin" ? "admin" : "p";
|
|
||||||
|
|
||||||
if (!new RegExp(`^/${path}`).test(router.asPath)) {
|
|
||||||
router.replace(`/${path}/${poll.urlId}`, undefined, { shallow: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
|
@ -58,7 +53,7 @@ const PollPage: NextPage = () => {
|
||||||
const verifyEmail = trpc.useMutation(["polls.verification.verify"], {
|
const verifyEmail = trpc.useMutation(["polls.verification.verify"], {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Your poll has been verified");
|
toast.success("Your poll has been verified");
|
||||||
queryClient.setQueryData(["polls.get", { urlId: poll.urlId }], {
|
queryClient.setQueryData(["polls.get", { urlId, admin }], {
|
||||||
...poll,
|
...poll,
|
||||||
verified: true,
|
verified: true,
|
||||||
});
|
});
|
||||||
|
@ -78,14 +73,14 @@ const PollPage: NextPage = () => {
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
const { code } = router.query;
|
const { code } = router.query;
|
||||||
if (typeof code === "string" && !poll.verified) {
|
if (typeof code === "string" && !poll.verified) {
|
||||||
verifyEmail.mutate({ code, pollId: poll.pollId });
|
verifyEmail.mutate({ code, pollId: poll.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (router.query.unsubscribe) {
|
if (router.query.unsubscribe) {
|
||||||
updatePollMutation(
|
updatePollMutation(
|
||||||
{ urlId: poll.urlId, notifications: false },
|
{ urlId: urlId, notifications: false },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Notifications have been disabled");
|
toast.success("Notifications have been disabled");
|
||||||
|
@ -97,7 +92,7 @@ const PollPage: NextPage = () => {
|
||||||
shallow: true,
|
shallow: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [plausible, poll.urlId, router, updatePollMutation]);
|
}, [plausible, urlId, router, updatePollMutation]);
|
||||||
|
|
||||||
const checkIfWideScreen = () => window.innerWidth > 640;
|
const checkIfWideScreen = () => window.innerWidth > 640;
|
||||||
|
|
||||||
|
@ -120,10 +115,13 @@ const PollPage: NextPage = () => {
|
||||||
[participants],
|
[participants],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isSharingVisible, setSharingVisible] = React.useState(
|
||||||
|
!!router.query.sharing,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<UserAvatarProvider seed={poll.pollId} names={names}>
|
<UserAvatarProvider seed={poll.id} names={names}>
|
||||||
<StandardLayout>
|
<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>
|
<Head>
|
||||||
<title>{poll.title}</title>
|
<title>{poll.title}</title>
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
@ -134,92 +132,144 @@ const PollPage: NextPage = () => {
|
||||||
width: Math.max(768, poll.options.length * 95 + 200 + 160),
|
width: Math.max(768, poll.options.length * 95 + 200 + 160),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
{admin ? (
|
||||||
<div className="mb-3 items-start px-4 md:flex md:space-x-4">
|
<>
|
||||||
<div className="mb-3 grow md:mb-0">
|
<div className="mb-4 flex space-x-2 px-4 md:justify-end md:px-0">
|
||||||
<div className="flex flex-col-reverse md:flex-row">
|
<NotificationsToggle />
|
||||||
<h1
|
<ManagePoll
|
||||||
data-testid="poll-title"
|
placement={isWideScreen ? "bottom-end" : "bottom-start"}
|
||||||
className="mb-2 grow text-3xl leading-tight"
|
/>
|
||||||
|
<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)}
|
<Sharing
|
||||||
</h1>
|
onHide={() => {
|
||||||
{poll.role === "admin" ? (
|
setSharingVisible(false);
|
||||||
<div className="mb-4 flex space-x-2 md:mb-2">
|
router.replace(
|
||||||
<NotificationsToggle />
|
`/admin/${router.query.urlId}`,
|
||||||
<ManagePoll
|
undefined,
|
||||||
placement={
|
{
|
||||||
isWideScreen ? "bottom-end" : "bottom-start"
|
shallow: true,
|
||||||
}
|
},
|
||||||
/>
|
);
|
||||||
<div>
|
}}
|
||||||
<Popover
|
/>
|
||||||
trigger={
|
</motion.div>
|
||||||
<Button type="primary" icon={<Share />}>
|
) : null}
|
||||||
Share
|
</AnimatePresence>
|
||||||
</Button>
|
{poll.verified === false ? (
|
||||||
}
|
<div className="m-4 overflow-hidden rounded-lg border p-4 md:mx-0 md:mt-0">
|
||||||
placement={isWideScreen ? "bottom-end" : undefined}
|
<UnverifiedPollNotice />
|
||||||
>
|
|
||||||
<Sharing links={poll.links} />
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
<a href={`/admin/${poll.adminUrlId}`} className="btn-default">
|
||||||
{poll.description ? (
|
Go to admin →
|
||||||
<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">
|
</a>
|
||||||
<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)
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{poll.closed ? (
|
||||||
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
<div className="flex bg-sky-100 py-3 px-4 text-sky-700 md:mb-4 md:rounded-lg md:shadow-sm">
|
||||||
<span className="text-xs font-semibold text-slate-500">Key:</span>
|
<div className="mr-2 rounded-md">
|
||||||
<span className="inline-flex items-center space-x-1">
|
<LockClosed className="w-6" />
|
||||||
<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 />
|
|
||||||
</div>
|
</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 />
|
<Discussion />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,6 @@ import smoothscroll from "smoothscroll-polyfill";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import ArrowLeft from "../icons/arrow-left.svg";
|
import ArrowLeft from "../icons/arrow-left.svg";
|
||||||
import ArrowRight from "../icons/arrow-right.svg";
|
import ArrowRight from "../icons/arrow-right.svg";
|
||||||
import PlusCircle from "../icons/plus-circle.svg";
|
|
||||||
import { useParticipants } from "../participants-provider";
|
import { useParticipants } from "../participants-provider";
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
import TimeZonePicker from "../time-zone-picker";
|
import TimeZonePicker from "../time-zone-picker";
|
||||||
|
@ -23,29 +22,25 @@ if (typeof window !== "undefined") {
|
||||||
|
|
||||||
const MotionButton = motion(Button);
|
const MotionButton = motion(Button);
|
||||||
|
|
||||||
const MotionParticipantFormRow = motion(ParticipantRowForm);
|
const minSidebarWidth = 200;
|
||||||
|
|
||||||
const minSidebarWidth = 180;
|
|
||||||
|
|
||||||
const Poll: React.VoidFunctionComponent = () => {
|
const Poll: React.VoidFunctionComponent = () => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
|
||||||
const { poll, targetTimeZone, setTargetTimeZone, options, userAlreadyVoted } =
|
const { poll, options, userAlreadyVoted, targetTimeZone, setTargetTimeZone } =
|
||||||
usePoll();
|
usePoll();
|
||||||
|
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
|
|
||||||
const { timeZone } = poll;
|
|
||||||
|
|
||||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||||
const [editingParticipantId, setEditingParticipantId] =
|
const [editingParticipantId, setEditingParticipantId] =
|
||||||
React.useState<string | null>(null);
|
React.useState<string | null>(null);
|
||||||
|
|
||||||
const actionColumnWidth = 140;
|
const actionColumnWidth = 140;
|
||||||
const columnWidth = Math.min(
|
const columnWidth = Math.min(
|
||||||
100,
|
130,
|
||||||
Math.max(
|
Math.max(
|
||||||
95,
|
90,
|
||||||
(width - minSidebarWidth - actionColumnWidth) / options.length,
|
(width - minSidebarWidth - actionColumnWidth) / options.length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -71,12 +66,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
||||||
const maxScrollPosition =
|
const maxScrollPosition =
|
||||||
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
|
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
|
||||||
|
|
||||||
const numberOfInvisibleColumns = options.length - numberOfVisibleColumns;
|
const shouldShowNewParticipantForm = !userAlreadyVoted && !poll.closed;
|
||||||
|
|
||||||
const [didUsePagination, setDidUsePagination] = React.useState(false);
|
|
||||||
|
|
||||||
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
|
|
||||||
React.useState(!userAlreadyVoted && !poll.closed);
|
|
||||||
|
|
||||||
const pollWidth =
|
const pollWidth =
|
||||||
sidebarWidth + options.length * columnWidth + actionColumnWidth;
|
sidebarWidth + options.length * columnWidth + actionColumnWidth;
|
||||||
|
@ -98,6 +88,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const participantListContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<PollContext.Provider
|
<PollContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -112,42 +103,31 @@ const Poll: React.VoidFunctionComponent = () => {
|
||||||
numberOfColumns: numberOfVisibleColumns,
|
numberOfColumns: numberOfVisibleColumns,
|
||||||
availableSpace,
|
availableSpace,
|
||||||
actionColumnWidth,
|
actionColumnWidth,
|
||||||
|
maxScrollPosition,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
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: `min(${pollWidth}px, calc(100vw - 3rem))` }}
|
style={{ width: pollWidth }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className=" border-t border-b bg-white shadow-sm md:rounded-lg md:border">
|
<div className="flex max-h-[calc(100vh-70px)] flex-col overflow-hidden bg-white">
|
||||||
<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">
|
{poll.timeZone ? (
|
||||||
<div className="flex h-14 items-center justify-end space-x-4 rounded-t-lg border-b bg-gray-50 px-4">
|
<div className="flex h-14 items-center justify-end space-x-4 border-b bg-gray-50 px-4">
|
||||||
{timeZone ? (
|
<div className="flex grow items-center">
|
||||||
<div className="flex grow items-center">
|
<div className="mr-2 text-sm font-medium text-slate-500">
|
||||||
<div className="mr-2 text-sm font-medium text-slate-500">
|
{t("timeZone")}
|
||||||
{t("timeZone")}
|
|
||||||
</div>
|
|
||||||
<TimeZonePicker
|
|
||||||
value={targetTimeZone}
|
|
||||||
onChange={setTargetTimeZone}
|
|
||||||
className="grow"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<TimeZonePicker
|
||||||
<div className="flex shrink-0">
|
value={targetTimeZone}
|
||||||
<Button
|
onChange={setTargetTimeZone}
|
||||||
type="primary"
|
className="grow"
|
||||||
disabled={shouldShowNewParticipantForm || poll.closed}
|
/>
|
||||||
icon={<PlusCircle />}
|
|
||||||
onClick={() => {
|
|
||||||
setShouldShowNewParticipantForm(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Participant
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
) : null}
|
||||||
|
<div>
|
||||||
|
<div className="flex border-b py-2">
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
|
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
|
||||||
style={{ width: sidebarWidth }}
|
style={{ width: sidebarWidth }}
|
||||||
|
@ -186,57 +166,55 @@ const Poll: React.VoidFunctionComponent = () => {
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
rounded={true}
|
rounded={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDidUsePagination(true);
|
|
||||||
goToNextPage();
|
goToNextPage();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{didUsePagination ? (
|
<ArrowRight className="h-4 w-4" />
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
`+${numberOfInvisibleColumns} more…`
|
|
||||||
)}
|
|
||||||
</MotionButton>
|
</MotionButton>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</PollContext.Provider>
|
</PollContext.Provider>
|
||||||
|
|
|
@ -33,6 +33,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
numberOfColumns,
|
numberOfColumns,
|
||||||
goToNextPage,
|
goToNextPage,
|
||||||
|
maxScrollPosition,
|
||||||
setScrollPosition,
|
setScrollPosition,
|
||||||
} = usePollContext();
|
} = usePollContext();
|
||||||
|
|
||||||
|
@ -85,9 +86,8 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<NameInput
|
<NameInput
|
||||||
autoFocus={true}
|
|
||||||
className={clsx("w-full", {
|
className={clsx("w-full", {
|
||||||
"input-error animate-wiggle": errors.name && submitCount > 0,
|
"input-error": errors.name && submitCount > 0,
|
||||||
})}
|
})}
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
{...field}
|
{...field}
|
||||||
|
@ -160,16 +160,28 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||||
<Button
|
{scrollPosition >= maxScrollPosition ? (
|
||||||
htmlType="submit"
|
<Button
|
||||||
icon={<Check />}
|
htmlType="submit"
|
||||||
type="primary"
|
icon={<Check />}
|
||||||
loading={isSubmitting}
|
type="primary"
|
||||||
data-testid="submitNewParticipant"
|
loading={isSubmitting}
|
||||||
>
|
data-testid="submitNewParticipant"
|
||||||
Save
|
>
|
||||||
</Button>
|
Save
|
||||||
<CompactButton onClick={onCancel} icon={X} />
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{scrollPosition < maxScrollPosition ? (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNextPage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onCancel ? <CompactButton onClick={onCancel} icon={X} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
|
@ -32,6 +32,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
isYou?: boolean;
|
isYou?: boolean;
|
||||||
|
participantId: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
name,
|
name,
|
||||||
editable,
|
editable,
|
||||||
|
@ -42,9 +43,14 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
||||||
columnWidth,
|
columnWidth,
|
||||||
isYou,
|
isYou,
|
||||||
color,
|
color,
|
||||||
|
participantId,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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
|
<div
|
||||||
className="flex shrink-0 items-center px-4"
|
className="flex shrink-0 items-center px-4"
|
||||||
style={{ width: sidebarWidth }}
|
style={{ width: sidebarWidth }}
|
||||||
|
@ -109,8 +115,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
|
|
||||||
const isAnonymous = !participant.userId && !participant.guestId;
|
const isAnonymous = !participant.userId && !participant.guestId;
|
||||||
|
|
||||||
const canEdit =
|
const canEdit = !poll.closed && (poll.admin || isYou || isAnonymous);
|
||||||
!poll.closed && (poll.role === "admin" || isYou || isAnonymous);
|
|
||||||
|
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
return (
|
return (
|
||||||
|
@ -125,7 +130,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
onSubmit={async ({ name, votes }) => {
|
onSubmit={async ({ name, votes }) => {
|
||||||
await updateParticipant.mutateAsync({
|
await updateParticipant.mutateAsync({
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
pollId: poll.pollId,
|
pollId: poll.id,
|
||||||
votes,
|
votes,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
|
@ -144,6 +149,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
votes={options.map(({ optionId }) => {
|
votes={options.map(({ optionId }) => {
|
||||||
return getVote(participant.id, optionId);
|
return getVote(participant.id, optionId);
|
||||||
})}
|
})}
|
||||||
|
participantId={participant.id}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
isYou={isYou}
|
isYou={isYou}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ export const PollContext = React.createContext<{
|
||||||
activeOptionId: string | null;
|
activeOptionId: string | null;
|
||||||
setActiveOptionId: (optionId: string | null) => void;
|
setActiveOptionId: (optionId: string | null) => void;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
|
maxScrollPosition: number;
|
||||||
setScrollPosition: (position: number) => void;
|
setScrollPosition: (position: number) => void;
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
|
@ -17,6 +18,7 @@ export const PollContext = React.createContext<{
|
||||||
activeOptionId: null,
|
activeOptionId: null,
|
||||||
setActiveOptionId: noop,
|
setActiveOptionId: noop,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
|
maxScrollPosition: 100,
|
||||||
setScrollPosition: noop,
|
setScrollPosition: noop,
|
||||||
columnWidth: 100,
|
columnWidth: 100,
|
||||||
sidebarWidth: 200,
|
sidebarWidth: 200,
|
||||||
|
|
|
@ -43,12 +43,12 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
onMouseOut={() => setActiveOptionId(null)}
|
onMouseOut={() => setActiveOptionId(null)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold leading-9">
|
<div className="leading-9">
|
||||||
<div className="text-sm uppercase text-slate-400">
|
<div className="text-xs font-semibold uppercase text-slate-500/75">
|
||||||
{option.dow}
|
{option.dow}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl">{option.day}</div>
|
<div className="text-2xl font-semibold">{option.day}</div>
|
||||||
<div className="text-xs font-medium uppercase text-slate-400/75">
|
<div className="text-xs font-medium uppercase text-slate-500/50">
|
||||||
{option.month}
|
{option.month}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,7 +27,8 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}> = ({ placement }) => {
|
}> = ({ placement }) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { poll, getParticipantsWhoVotedForOption, setDeleted } = usePoll();
|
const { poll, getParticipantsWhoVotedForOption, setDeleted, urlId } =
|
||||||
|
usePoll();
|
||||||
|
|
||||||
const { exportToCsv } = useCsvExporter();
|
const { exportToCsv } = useCsvExporter();
|
||||||
|
|
||||||
|
@ -98,7 +99,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
const onOk = () => {
|
const onOk = () => {
|
||||||
updatePollMutation(
|
updatePollMutation(
|
||||||
{
|
{
|
||||||
urlId: poll.urlId,
|
urlId: urlId,
|
||||||
timeZone: data.timeZone,
|
timeZone: data.timeZone,
|
||||||
optionsToDelete: optionsToDelete.map(({ id }) => id),
|
optionsToDelete: optionsToDelete.map(({ id }) => id),
|
||||||
optionsToAdd,
|
optionsToAdd,
|
||||||
|
@ -165,7 +166,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
//submit
|
//submit
|
||||||
updatePollMutation(
|
updatePollMutation(
|
||||||
{ urlId: poll.urlId, ...data },
|
{ urlId, ...data },
|
||||||
{ onSuccess: closePollDetailsModal },
|
{ onSuccess: closePollDetailsModal },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -195,17 +196,13 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
icon={LockOpen}
|
icon={LockOpen}
|
||||||
label="Unlock poll"
|
label="Unlock poll"
|
||||||
onClick={() =>
|
onClick={() => updatePollMutation({ urlId, closed: false })}
|
||||||
updatePollMutation({ urlId: poll.urlId, closed: false })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
icon={LockClosed}
|
icon={LockClosed}
|
||||||
label="Lock poll"
|
label="Lock poll"
|
||||||
onClick={() =>
|
onClick={() => updatePollMutation({ urlId, closed: true })}
|
||||||
updatePollMutation({ urlId: poll.urlId, closed: true })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
|
@ -221,7 +218,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
setDeleted(true);
|
setDeleted(true);
|
||||||
}}
|
}}
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
urlId={poll.urlId}
|
urlId={urlId}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
footer: null,
|
footer: null,
|
||||||
|
|
|
@ -10,7 +10,6 @@ import Check from "@/components/icons/check.svg";
|
||||||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||||
import PlusCircle from "@/components/icons/plus-circle.svg";
|
import PlusCircle from "@/components/icons/plus-circle.svg";
|
||||||
import Save from "@/components/icons/save.svg";
|
|
||||||
import Trash from "@/components/icons/trash.svg";
|
import Trash from "@/components/icons/trash.svg";
|
||||||
import { usePoll } from "@/components/poll-context";
|
import { usePoll } from "@/components/poll-context";
|
||||||
|
|
||||||
|
@ -45,10 +44,11 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
getParticipantById,
|
getParticipantById,
|
||||||
optionIds,
|
optionIds,
|
||||||
getVote,
|
getVote,
|
||||||
|
userAlreadyVoted,
|
||||||
} = pollContext;
|
} = pollContext;
|
||||||
|
|
||||||
const { participants } = useParticipants();
|
const { participants } = useParticipants();
|
||||||
const { timeZone, role } = poll;
|
const { timeZone } = poll;
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
|
@ -60,38 +60,34 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reset, handleSubmit, control, formState } = form;
|
const { reset, handleSubmit, control, formState } = form;
|
||||||
const [selectedParticipantId, setSelectedParticipantId] =
|
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
|
||||||
React.useState<string>();
|
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
|
const selectedParticipant = selectedParticipantId
|
||||||
? getParticipantById(selectedParticipantId)
|
? getParticipantById(selectedParticipantId)
|
||||||
: undefined;
|
: 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);
|
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 { t } = useTranslation("app");
|
||||||
|
|
||||||
const updateParticipant = useUpdateParticipantMutation();
|
const updateParticipant = useUpdateParticipantMutation();
|
||||||
|
@ -99,20 +95,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
const addParticipant = useAddParticipantMutation();
|
const addParticipant = useAddParticipantMutation();
|
||||||
const confirmDeleteParticipant = useDeleteParticipantModal();
|
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 (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form
|
<form
|
||||||
|
@ -121,7 +103,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
onSubmit={handleSubmit(async ({ name, votes }) => {
|
onSubmit={handleSubmit(async ({ name, votes }) => {
|
||||||
if (selectedParticipant) {
|
if (selectedParticipant) {
|
||||||
await updateParticipant.mutateAsync({
|
await updateParticipant.mutateAsync({
|
||||||
pollId: poll.pollId,
|
pollId: poll.id,
|
||||||
participantId: selectedParticipant.id,
|
participantId: selectedParticipant.id,
|
||||||
name,
|
name,
|
||||||
votes: normalizeVotes(optionIds, votes),
|
votes: normalizeVotes(optionIds, votes),
|
||||||
|
@ -129,7 +111,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} else {
|
} else {
|
||||||
const newParticipant = await addParticipant.mutateAsync({
|
const newParticipant = await addParticipant.mutateAsync({
|
||||||
pollId: poll.pollId,
|
pollId: poll.id,
|
||||||
name,
|
name,
|
||||||
votes: normalizeVotes(optionIds, votes),
|
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="sticky top-[47px] z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Listbox
|
{!isEditing ? (
|
||||||
value={selectedParticipantId}
|
<Listbox
|
||||||
onChange={(participantId) => {
|
value={selectedParticipantId}
|
||||||
setSelectedParticipantId(participantId);
|
onChange={(participantId) => {
|
||||||
}}
|
setSelectedParticipantId(participantId);
|
||||||
disabled={isEditing}
|
}}
|
||||||
>
|
disabled={isEditing}
|
||||||
<div className="menu min-w-0 grow">
|
>
|
||||||
<Listbox.Button
|
<div className="menu min-w-0 grow">
|
||||||
as={Button}
|
<Listbox.Button
|
||||||
className="w-full"
|
as={Button}
|
||||||
disabled={!isEditing}
|
className="w-full"
|
||||||
data-testid="participant-selector"
|
disabled={!isEditing}
|
||||||
>
|
data-testid="participant-selector"
|
||||||
<div className="min-w-0 grow text-left">
|
>
|
||||||
{selectedParticipant ? (
|
<div className="min-w-0 grow text-left">
|
||||||
<div className="flex items-center space-x-2">
|
{selectedParticipant ? (
|
||||||
<UserAvatar
|
<div className="flex items-center space-x-2">
|
||||||
name={selectedParticipant.name}
|
<UserAvatar
|
||||||
showName={true}
|
name={selectedParticipant.name}
|
||||||
isYou={session.ownsObject(selectedParticipant)}
|
showName={true}
|
||||||
/>
|
isYou={session.ownsObject(selectedParticipant)}
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
t("participantCount", { count: participants.length })
|
) : (
|
||||||
)}
|
t("participantCount", { count: participants.length })
|
||||||
</div>
|
)}
|
||||||
<ChevronDown className="h-5 shrink-0" />
|
</div>
|
||||||
</Listbox.Button>
|
<ChevronDown className="h-5 shrink-0" />
|
||||||
<Listbox.Options
|
</Listbox.Button>
|
||||||
as={motion.div}
|
<Listbox.Options
|
||||||
transition={{
|
as={motion.div}
|
||||||
duration: 0.1,
|
transition={{
|
||||||
}}
|
duration: 0.1,
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
}}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
className="menu-items max-h-72 w-full overflow-auto"
|
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 value={undefined} className={styleMenuItem}>
|
||||||
</Listbox.Option>
|
{t("participantCount", { count: participants.length })}
|
||||||
{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.Option>
|
||||||
))}
|
{participants.map((participant) => (
|
||||||
</Listbox.Options>
|
<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>
|
</div>
|
||||||
</Listbox>
|
)}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -215,7 +216,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
disabled={
|
disabled={
|
||||||
poll.closed ||
|
poll.closed ||
|
||||||
// if user is participant (not admin)
|
// if user is participant (not admin)
|
||||||
(role === "participant" &&
|
(!poll.admin &&
|
||||||
// and does not own this participant
|
// and does not own this participant
|
||||||
!session.ownsObject(selectedParticipant) &&
|
!session.ownsObject(selectedParticipant) &&
|
||||||
// and the participant has been claimed by a different user
|
// and the participant has been claimed by a different user
|
||||||
|
@ -240,7 +241,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
disabled={
|
disabled={
|
||||||
poll.closed ||
|
poll.closed ||
|
||||||
// if user is participant (not admin)
|
// if user is participant (not admin)
|
||||||
(role === "participant" &&
|
(!poll.admin &&
|
||||||
// and does not own this participant
|
// and does not own this participant
|
||||||
!session.ownsObject(selectedParticipant) &&
|
!session.ownsObject(selectedParticipant) &&
|
||||||
// or the participant has been claimed by a different user
|
// or the participant has been claimed by a different user
|
||||||
|
@ -256,7 +257,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !userAlreadyVoted ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusCircle />}
|
icon={<PlusCircle />}
|
||||||
|
@ -271,7 +272,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
>
|
>
|
||||||
New
|
New
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{timeZone ? (
|
{timeZone ? (
|
||||||
<TimeZonePicker
|
<TimeZonePicker
|
||||||
|
@ -294,28 +295,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
return `${option.month} ${option.year}`;
|
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>
|
<AnimatePresence>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -332,36 +311,16 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
||||||
transition: { duration: 0.2 },
|
transition: { duration: 0.2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="space-y-3 border-t bg-gray-50 p-3">
|
||||||
ref={submitContainerRef}
|
<Button
|
||||||
className="space-y-3 border-t bg-gray-50 p-3"
|
icon={<Check />}
|
||||||
>
|
className="w-full"
|
||||||
<div className="flex space-x-3">
|
htmlType="submit"
|
||||||
<div className="grow">
|
type="primary"
|
||||||
<Controller
|
loading={formState.isSubmitting}
|
||||||
name="name"
|
>
|
||||||
control={control}
|
Save
|
||||||
rules={{ validate: requiredString }}
|
</Button>
|
||||||
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -29,7 +29,7 @@ const CollapsibleContainer: React.VoidFunctionComponent<{
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ className, children, expanded }) => {
|
}> = ({ className, children, expanded }) => {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence initial={false}>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={{
|
variants={{
|
||||||
|
@ -92,7 +92,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
||||||
<div>
|
<div>
|
||||||
{noVotes ? (
|
{noVotes ? (
|
||||||
<div className="rounded-lg bg-slate-50 p-2 text-center text-slate-400">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-x-4">
|
<div className="grid grid-cols-2 gap-x-4">
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const useAddParticipantMutation = () => {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["polls.participants.list", { pollId: participant.pollId }],
|
["polls.participants.list", { pollId: participant.pollId }],
|
||||||
(existingParticipants = []) => {
|
(existingParticipants = []) => {
|
||||||
return [participant, ...existingParticipants];
|
return [...existingParticipants, participant];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
session.refresh();
|
session.refresh();
|
||||||
|
@ -79,12 +79,12 @@ export const useDeleteParticipantMutation = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUpdatePollMutation = () => {
|
export const useUpdatePollMutation = () => {
|
||||||
const { poll } = usePoll();
|
const { urlId, admin } = usePoll();
|
||||||
const plausible = usePlausible();
|
const plausible = usePlausible();
|
||||||
const queryClient = trpc.useContext();
|
const queryClient = trpc.useContext();
|
||||||
return trpc.useMutation(["polls.update"], {
|
return trpc.useMutation(["polls.update"], {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["polls.get", { urlId: poll.urlId }], data);
|
queryClient.setQueryData(["polls.get", { urlId, admin }], data);
|
||||||
plausible("Updated poll");
|
plausible("Updated poll");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Tooltip from "../tooltip";
|
||||||
import { useUpdatePollMutation } from "./mutations";
|
import { useUpdatePollMutation } from "./mutations";
|
||||||
|
|
||||||
const NotificationsToggle: React.VoidFunctionComponent = () => {
|
const NotificationsToggle: React.VoidFunctionComponent = () => {
|
||||||
const { poll } = usePoll();
|
const { poll, urlId } = usePoll();
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [isUpdatingNotifications, setIsUpdatingNotifications] =
|
const [isUpdatingNotifications, setIsUpdatingNotifications] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
|
@ -25,7 +25,7 @@ const NotificationsToggle: React.VoidFunctionComponent = () => {
|
||||||
poll.verified ? (
|
poll.verified ? (
|
||||||
poll.notifications ? (
|
poll.notifications ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-primary-300 font-medium">
|
<div className="font-medium text-primary-300">
|
||||||
Notifications are on
|
Notifications are on
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
|
@ -37,7 +37,7 @@ const NotificationsToggle: React.VoidFunctionComponent = () => {
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
b: (
|
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);
|
setIsUpdatingNotifications(true);
|
||||||
updatePollMutation(
|
updatePollMutation(
|
||||||
{
|
{
|
||||||
urlId: poll.urlId,
|
urlId,
|
||||||
notifications: !poll.notifications,
|
notifications: !poll.notifications,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -2,11 +2,8 @@ import { formatRelative } from "date-fns";
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { trpc } from "../../utils/trpc";
|
|
||||||
import Badge from "../badge";
|
import Badge from "../badge";
|
||||||
import { Button } from "../button";
|
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
import Popover from "../popover";
|
|
||||||
import { usePreferences } from "../preferences/use-preferences";
|
import { usePreferences } from "../preferences/use-preferences";
|
||||||
import Tooltip from "../tooltip";
|
import Tooltip from "../tooltip";
|
||||||
|
|
||||||
|
@ -14,12 +11,9 @@ const PollSubheader: React.VoidFunctionComponent = () => {
|
||||||
const { poll } = usePoll();
|
const { poll } = usePoll();
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { locale } = usePreferences();
|
const { locale } = usePreferences();
|
||||||
const requestVerificationEmail = trpc.useMutation(
|
|
||||||
"polls.verification.request",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500/75 lg:text-lg">
|
||||||
<div className="md:inline">
|
<div className="md:inline">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="createdBy"
|
i18nKey="createdBy"
|
||||||
|
@ -28,74 +22,30 @@ const PollSubheader: React.VoidFunctionComponent = () => {
|
||||||
name: poll.authorName,
|
name: poll.authorName,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
b: <span className="text-primary-500 font-medium" />,
|
b: <span />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{poll.legacy && poll.admin ? (
|
||||||
<span className="inline-flex items-center space-x-1">
|
<Tooltip
|
||||||
{poll.role === "admin" && !poll.demo ? (
|
width={400}
|
||||||
poll.verified ? (
|
content="This poll was created with an older version of Rallly. Some features might not work."
|
||||||
<Badge color="green">Verified</Badge>
|
>
|
||||||
) : (
|
<Badge color="amber" className="ml-1">
|
||||||
<Popover
|
Legacy
|
||||||
trigger={
|
</Badge>
|
||||||
<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">
|
</Tooltip>
|
||||||
Unverified
|
) : null}
|
||||||
</button>
|
{poll.demo ? (
|
||||||
}
|
<Tooltip content={<Trans t={t} i18nKey="demoPollNotice" />}>
|
||||||
>
|
<Badge color="blue" className="ml-1">
|
||||||
<div className="max-w-sm">
|
Demo
|
||||||
<div className="mb-4">
|
</Badge>
|
||||||
<Trans
|
</Tooltip>
|
||||||
t={t}
|
) : null}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:inline"> • </span>
|
<span className="hidden md:inline"> • </span>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{formatRelative(new Date(poll.createdAt), new Date(), {
|
{formatRelative(poll.createdAt, new Date(), {
|
||||||
locale,
|
locale,
|
||||||
})}
|
})}
|
||||||
</span>
|
</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 { render } = useModalContext();
|
||||||
|
|
||||||
const deleteParticipant = useDeleteParticipantMutation();
|
const deleteParticipant = useDeleteParticipantMutation();
|
||||||
const {
|
const { poll } = usePoll();
|
||||||
poll: { pollId },
|
|
||||||
} = usePoll();
|
|
||||||
|
|
||||||
return (participantId: string) => {
|
return (participantId: string) => {
|
||||||
return render({
|
return render({
|
||||||
|
@ -21,7 +19,7 @@ export const useDeleteParticipantModal = () => {
|
||||||
okText: "Delete",
|
okText: "Delete",
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
deleteParticipant.mutate({
|
deleteParticipant.mutate({
|
||||||
pollId,
|
pollId: poll.id,
|
||||||
participantId,
|
participantId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -35,8 +35,8 @@ export const Profile: React.VoidFunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl py-4 lg:mx-0">
|
<div className="mx-auto max-w-3xl py-4 lg:mx-0">
|
||||||
<div className="mb-4 flex items-center px-4">
|
<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">
|
<div className="mr-4 inline-flex h-14 w-14 items-center justify-center rounded-lg bg-primary-50">
|
||||||
<User className="text-primary-500 h-7" />
|
<User className="h-7 text-primary-500" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
@ -71,9 +71,9 @@ export const Profile: React.VoidFunctionComponent = () => {
|
||||||
<div className="sm:table-cell sm:p-4">
|
<div className="sm:table-cell sm:p-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Calendar className="text-primary-500 mr-2 mt-[1px] h-5" />
|
<Calendar className="mr-2 mt-[1px] h-5 text-primary-500" />
|
||||||
<Link href={`/admin/${poll.links[0].urlId}`}>
|
<Link href={`/admin/${poll.adminUrlId}`}>
|
||||||
<a className="hover:text-primary-500 text-slate-700 hover:no-underline">
|
<a className="text-slate-700 hover:text-primary-500 hover:no-underline">
|
||||||
<div>{poll.title}</div>
|
<div>{poll.title}</div>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -1,102 +1,79 @@
|
||||||
import { Link, Role } from "@prisma/client";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import { usePlausible } from "next-plausible";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useCopyToClipboard } from "react-use";
|
import { useCopyToClipboard } from "react-use";
|
||||||
|
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
import { usePoll } from "./poll-context";
|
||||||
|
|
||||||
export interface SharingProps {
|
export interface SharingProps {
|
||||||
links: Link[];
|
onHide: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useRoleData = (): Record<
|
const Sharing: React.VoidFunctionComponent<SharingProps> = ({
|
||||||
Role,
|
onHide,
|
||||||
{ path: string; label: string; description: string }
|
className,
|
||||||
> => {
|
}) => {
|
||||||
|
const { poll } = usePoll();
|
||||||
const { t } = useTranslation("app");
|
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 [state, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
const plausible = usePlausible();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
toast.error(`Unable to copy value: ${state.error.message}`);
|
toast.error(`Unable to copy value: ${state.error.message}`);
|
||||||
}
|
}
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
const [role, setRole] = React.useState<Role>("participant");
|
const participantUrl = `${window.location.origin}/p/${poll.participantUrlId}`;
|
||||||
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 [didCopy, setDidCopy] = React.useState(false);
|
const [didCopy, setDidCopy] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] md:w-[400px]">
|
<div className={clsx("card p-4", className)}>
|
||||||
<div className="segment-button mb-3 inline-flex">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold text-slate-700">
|
||||||
|
Share via link
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className={clsx({
|
onClick={onHide}
|
||||||
"segment-button-active": role === "participant",
|
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"
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
setRole("participant");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{roleData["participant"].label}
|
Hide
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={clsx({
|
|
||||||
"segment-button-active": role === "admin",
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
setRole("admin");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{roleData["admin"].label}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex flex-col space-y-3 lg:flex-row lg:space-y-0 lg:space-x-3">
|
<div className="mb-4 text-slate-600">
|
||||||
<input readOnly={true} className="input lg:w-[280px]" value={pollUrl} />
|
<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
|
<Button
|
||||||
className="w-24 shrink-0"
|
|
||||||
disabled={didCopy}
|
disabled={didCopy}
|
||||||
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(pollUrl);
|
copyToClipboard(participantUrl);
|
||||||
setDidCopy(true);
|
setDidCopy(true);
|
||||||
setTimeout(() => setDidCopy(false), 1000);
|
setTimeout(() => {
|
||||||
plausible("Copied share link", {
|
setDidCopy(false);
|
||||||
props: {
|
}, 1000);
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
className="md:absolute md:top-1/2 md:right-3 md:-translate-y-1/2"
|
||||||
>
|
>
|
||||||
{didCopy ? "Copied" : "Copy Link"}
|
{didCopy ? "Copied" : "Copy Link"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-500">{roleData[link.role].description}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,7 +30,7 @@ const HomeLink = () => {
|
||||||
return (
|
return (
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<a>
|
<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>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
@ -41,7 +41,10 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
||||||
}> = ({ openLoginModal }) => {
|
}> = ({ openLoginModal }) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
return (
|
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>
|
<div>
|
||||||
<HomeLink />
|
<HomeLink />
|
||||||
</div>
|
</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"
|
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">
|
<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>
|
||||||
<div className="hidden max-w-[120px] truncate font-medium xs:block">
|
<div className="hidden max-w-[120px] truncate font-medium xs:block">
|
||||||
{user.shortName}
|
{user.shortName}
|
||||||
|
@ -89,7 +92,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex items-center whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
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>
|
<span className="ml-2 hidden sm:block">Preferences</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@ -103,7 +106,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex items-center rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
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>
|
<span className="ml-2 hidden sm:block">Menu</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@ -160,7 +163,7 @@ const UserDropdown: React.VoidFunctionComponent<
|
||||||
content: (
|
content: (
|
||||||
<div className="w-96 max-w-full p-6 pt-28">
|
<div className="w-96 max-w-full p-6 pt-28">
|
||||||
<div className="absolute left-0 -top-8 w-full text-center">
|
<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" />
|
<User className="h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div className="">
|
||||||
|
@ -251,7 +254,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href="/new">
|
<Link href="/new">
|
||||||
<a className="group mb-1 flex items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20">
|
<a className="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>
|
<span className="grow text-left">New Poll</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</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"
|
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"
|
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>
|
<span className="grow text-left">Support</span>
|
||||||
</a>
|
</a>
|
||||||
<Popover
|
<Popover
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
trigger={
|
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">
|
<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>
|
<span className="grow text-left">Preferences</span>
|
||||||
<DotsVertical className="h-4 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100" />
|
<DotsVertical className="h-4 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -281,7 +284,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
||||||
onClick={openLoginModal}
|
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"
|
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>
|
<span className="grow text-left">Login</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -301,7 +304,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center space-x-3">
|
<div className="flex w-full items-center space-x-3">
|
||||||
<div className="relative">
|
<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>
|
||||||
<div className="grow overflow-hidden">
|
<div className="grow overflow-hidden">
|
||||||
<div className="truncate font-medium leading-snug text-slate-600">
|
<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 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>
|
<div>
|
||||||
<Link href="https://rallly.co">
|
<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" />
|
<Logo className="h-5" />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -337,30 +340,30 @@ const StandardLayout: React.VoidFunctionComponent<{
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://support.rallly.co"
|
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"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Support
|
Support
|
||||||
</a>
|
</a>
|
||||||
<Link href="https://github.com/lukevella/rallly/discussions">
|
<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
|
Discussions
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://blog.rallly.co">
|
<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
|
Blog
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden text-slate-300 lg:block">•</div>
|
<div className="hidden text-slate-300 lg:block">•</div>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
<Link href="https://twitter.com/ralllyco">
|
<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" />
|
<Twitter className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/lukevella/rallly">
|
<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" />
|
<Github className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -368,7 +371,7 @@ const StandardLayout: React.VoidFunctionComponent<{
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-slate-300 lg:block">•</div>
|
<div className="hidden text-slate-300 lg:block">•</div>
|
||||||
<Link href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E">
|
<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" />
|
<Cash className="mr-1 inline-block w-5" />
|
||||||
<span>Donate</span>
|
<span>Donate</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -7,18 +7,28 @@ export interface TextInputProps
|
||||||
HTMLInputElement
|
HTMLInputElement
|
||||||
> {
|
> {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
|
proportions?: "lg" | "md";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
||||||
function TextInput({ className, error, ...forwardProps }, ref) {
|
function TextInput(
|
||||||
|
{ className, error, proportions: size = "md", ...forwardProps },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="text"
|
type="text"
|
||||||
className={clsx("input", className, {
|
className={clsx(
|
||||||
"input-error": error,
|
"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",
|
||||||
"bg-slate-50 text-slate-500": forwardProps.disabled,
|
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}
|
{...forwardProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
import {
|
||||||
|
flip,
|
||||||
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
useFloating,
|
||||||
|
} from "@floating-ui/react-dom-interactions";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
@ -119,6 +126,23 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
||||||
}> = ({ value, onChange, onBlur, className, style, disabled }) => {
|
}> = ({ value, onChange, onBlur, className, style, disabled }) => {
|
||||||
const { options, findFuzzyTz } = useTimeZones();
|
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(
|
const timeZoneOptions = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
@ -164,7 +188,11 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
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 */}
|
{/* Remove generic params once Combobox.Input can infer the types */}
|
||||||
<Combobox.Input<"input", TimeZoneOption>
|
<Combobox.Input<"input", TimeZoneOption>
|
||||||
className="input w-full pr-8"
|
className="input w-full pr-8"
|
||||||
|
@ -182,17 +210,27 @@ const TimeZonePicker: React.VoidFunctionComponent<{
|
||||||
<ChevronDown className="h-5 w-5" />
|
<ChevronDown className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
</Combobox.Button>
|
</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>
|
||||||
{filteredTimeZones.map((timeZone) => (
|
<Combobox.Options
|
||||||
<Combobox.Option
|
ref={floating}
|
||||||
key={timeZone.value}
|
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"
|
||||||
className={styleMenuItem}
|
style={{
|
||||||
value={timeZone}
|
position: strategy,
|
||||||
>
|
left: x ?? "",
|
||||||
{timeZone.label}
|
top: y ?? "",
|
||||||
</Combobox.Option>
|
}}
|
||||||
))}
|
>
|
||||||
</Combobox.Options>
|
{filteredTimeZones.map((timeZone) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={timeZone.value}
|
||||||
|
className={styleMenuItem}
|
||||||
|
value={timeZone}
|
||||||
|
>
|
||||||
|
{timeZone.label}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
|
|
|
@ -55,23 +55,15 @@ export default async function handler(
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
urlId: true,
|
id: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "asc", // oldest first
|
createdAt: "asc", // oldest first
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).map(({ urlId }) => urlId);
|
).map(({ id }) => id);
|
||||||
|
|
||||||
if (pollIdsToDelete.length !== 0) {
|
if (pollIdsToDelete.length !== 0) {
|
||||||
// Delete links
|
|
||||||
await prisma.link.deleteMany({
|
|
||||||
where: {
|
|
||||||
pollId: {
|
|
||||||
in: pollIdsToDelete,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Delete comments
|
// Delete comments
|
||||||
await prisma.comment.deleteMany({
|
await prisma.comment.deleteMany({
|
||||||
where: {
|
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
|
// Delete polls
|
||||||
// Using execute raw to bypass soft delete middelware
|
// 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,
|
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 { GetServerSideProps, NextPage } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
import { usePlausible } from "next-plausible";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import FullPageLoader from "@/components/full-page-loader";
|
import FullPageLoader from "@/components/full-page-loader";
|
||||||
|
@ -14,42 +12,30 @@ import { SessionProps, withSession } from "@/components/session";
|
||||||
import { ParticipantsProvider } from "../components/participants-provider";
|
import { ParticipantsProvider } from "../components/participants-provider";
|
||||||
import { withSessionSsr } from "../utils/auth";
|
import { withSessionSsr } from "../utils/auth";
|
||||||
import { trpc } from "../utils/trpc";
|
import { trpc } from "../utils/trpc";
|
||||||
import { GetPollApiResponse } from "../utils/trpc/types";
|
|
||||||
import Custom404 from "./404";
|
import Custom404 from "./404";
|
||||||
|
|
||||||
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
||||||
|
|
||||||
const PollPageLoader: NextPage<SessionProps> = () => {
|
const PollPageLoader: NextPage<SessionProps> = () => {
|
||||||
const { query } = useRouter();
|
const { query, asPath } = useRouter();
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const urlId = query.urlId as string;
|
const urlId = query.urlId as string;
|
||||||
const plausible = usePlausible();
|
|
||||||
const [notFound, setNotFound] = React.useState(false);
|
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: () => {
|
onError: () => {
|
||||||
if (process.env.NEXT_PUBLIC_LEGACY_POLLS === "1") {
|
setNotFound(true);
|
||||||
axios
|
|
||||||
.get<GetPollApiResponse>(`/api/legacy/${urlId}`)
|
|
||||||
.then(({ data }) => {
|
|
||||||
plausible("Converted legacy event");
|
|
||||||
setLegacyPoll(data);
|
|
||||||
})
|
|
||||||
.catch(() => setNotFound(true));
|
|
||||||
} else {
|
|
||||||
setNotFound(true);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const poll = pollQuery.data ?? legacyPoll;
|
const poll = pollQuery.data;
|
||||||
|
|
||||||
if (poll) {
|
if (poll) {
|
||||||
return (
|
return (
|
||||||
<ParticipantsProvider pollId={poll.pollId}>
|
<ParticipantsProvider pollId={poll.id}>
|
||||||
<PollContextProvider value={poll}>
|
<PollContextProvider poll={poll} urlId={urlId} admin={admin}>
|
||||||
<PollPage />
|
<PollPage />
|
||||||
</PollContextProvider>
|
</PollContextProvider>
|
||||||
</ParticipantsProvider>
|
</ParticipantsProvider>
|
||||||
|
|
|
@ -7,12 +7,6 @@ import { absoluteUrl } from "../../utils/absolute-url";
|
||||||
import { sendEmailTemplate } from "../../utils/api-utils";
|
import { sendEmailTemplate } from "../../utils/api-utils";
|
||||||
import { createToken } from "../../utils/auth";
|
import { createToken } from "../../utils/auth";
|
||||||
import { nanoid } from "../../utils/nanoid";
|
import { nanoid } from "../../utils/nanoid";
|
||||||
import {
|
|
||||||
createPollResponse,
|
|
||||||
getDefaultPollInclude,
|
|
||||||
getLink,
|
|
||||||
getPollFromLink,
|
|
||||||
} from "../../utils/queries";
|
|
||||||
import { GetPollApiResponse } from "../../utils/trpc/types";
|
import { GetPollApiResponse } from "../../utils/trpc/types";
|
||||||
import { createRouter } from "../createRouter";
|
import { createRouter } from "../createRouter";
|
||||||
import { comments } from "./polls/comments";
|
import { comments } from "./polls/comments";
|
||||||
|
@ -20,6 +14,66 @@ import { demo } from "./polls/demo";
|
||||||
import { participants } from "./polls/participants";
|
import { participants } from "./polls/participants";
|
||||||
import { verification } from "./polls/verification";
|
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()
|
export const polls = createRouter()
|
||||||
.merge("demo.", demo)
|
.merge("demo.", demo)
|
||||||
.merge("participants.", participants)
|
.merge("participants.", participants)
|
||||||
|
@ -39,12 +93,12 @@ export const polls = createRouter()
|
||||||
options: z.string().array(),
|
options: z.string().array(),
|
||||||
demo: z.boolean().optional(),
|
demo: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ ctx, input }) => {
|
resolve: async ({ ctx, input }): Promise<{ urlId: string }> => {
|
||||||
const adminUrlId = await nanoid();
|
const adminUrlId = await nanoid();
|
||||||
|
|
||||||
const poll = await prisma.poll.create({
|
const poll = await prisma.poll.create({
|
||||||
data: {
|
data: {
|
||||||
urlId: await nanoid(),
|
id: await nanoid(),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
timeZone: input.timeZone,
|
timeZone: input.timeZone,
|
||||||
|
@ -55,6 +109,8 @@ export const polls = createRouter()
|
||||||
verified:
|
verified:
|
||||||
ctx.session.user?.isGuest === false &&
|
ctx.session.user?.isGuest === false &&
|
||||||
ctx.session.user.email === input.user.email,
|
ctx.session.user.email === input.user.email,
|
||||||
|
adminUrlId,
|
||||||
|
participantUrlId: await nanoid(),
|
||||||
user: {
|
user: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
where: {
|
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 {
|
} else {
|
||||||
const verificationCode = await createToken({
|
const verificationCode = await createToken({
|
||||||
pollId: poll.urlId,
|
pollId: poll.id,
|
||||||
});
|
});
|
||||||
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
|
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
|
||||||
|
|
||||||
|
@ -131,16 +173,38 @@ export const polls = createRouter()
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { urlId: adminUrlId, authorName: poll.authorName };
|
return { urlId: adminUrlId };
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.query("get", {
|
.query("get", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
urlId: z.string(),
|
urlId: z.string(),
|
||||||
|
admin: z.boolean(),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ input }): Promise<GetPollApiResponse> => {
|
resolve: async ({ input, ctx }): Promise<GetPollApiResponse> => {
|
||||||
const link = await getLink(input.urlId);
|
const poll = await prisma.poll.findFirst({
|
||||||
return await getPollFromLink(link);
|
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", {
|
.mutation("update", {
|
||||||
|
@ -156,13 +220,7 @@ export const polls = createRouter()
|
||||||
closed: z.boolean().optional(),
|
closed: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ input }): Promise<GetPollApiResponse> => {
|
resolve: async ({ input }): Promise<GetPollApiResponse> => {
|
||||||
const link = await getLink(input.urlId);
|
const pollId = await getPollIdFromAdminUrlId(input.urlId);
|
||||||
|
|
||||||
if (link.role !== "admin") {
|
|
||||||
throw new Error("Use admin link to update poll");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pollId } = link;
|
|
||||||
|
|
||||||
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
|
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
|
||||||
await prisma.option.deleteMany({
|
await prisma.option.deleteMany({
|
||||||
|
@ -185,8 +243,9 @@ export const polls = createRouter()
|
||||||
}
|
}
|
||||||
|
|
||||||
const poll = await prisma.poll.update({
|
const poll = await prisma.poll.update({
|
||||||
|
select: defaultSelectFields,
|
||||||
where: {
|
where: {
|
||||||
urlId: pollId,
|
id: pollId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
@ -196,10 +255,9 @@ export const polls = createRouter()
|
||||||
notifications: input.notifications,
|
notifications: input.notifications,
|
||||||
closed: input.closed,
|
closed: input.closed,
|
||||||
},
|
},
|
||||||
include: getDefaultPollInclude(link.role === "admin"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return createPollResponse(poll, link);
|
return { ...poll, admin: true };
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation("delete", {
|
.mutation("delete", {
|
||||||
|
@ -207,15 +265,8 @@ export const polls = createRouter()
|
||||||
urlId: z.string(),
|
urlId: z.string(),
|
||||||
}),
|
}),
|
||||||
resolve: async ({ input: { urlId } }) => {
|
resolve: async ({ input: { urlId } }) => {
|
||||||
const link = await getLink(urlId);
|
const pollId = await getPollIdFromAdminUrlId(urlId);
|
||||||
if (link.role !== "admin") {
|
await prisma.poll.delete({ where: { id: pollId } });
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "Tried to delete poll using participant url",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.poll.delete({ where: { urlId: link.pollId } });
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation("touch", {
|
.mutation("touch", {
|
||||||
|
@ -225,7 +276,7 @@ export const polls = createRouter()
|
||||||
resolve: async ({ input: { pollId } }) => {
|
resolve: async ({ input: { pollId } }) => {
|
||||||
await prisma.poll.update({
|
await prisma.poll.update({
|
||||||
where: {
|
where: {
|
||||||
urlId: pollId,
|
id: pollId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
touchedAt: new Date(),
|
touchedAt: new Date(),
|
||||||
|
|
|
@ -3,7 +3,6 @@ import addMinutes from "date-fns/addMinutes";
|
||||||
|
|
||||||
import { prisma } from "~/prisma/db";
|
import { prisma } from "~/prisma/db";
|
||||||
|
|
||||||
import { absoluteUrl } from "../../../utils/absolute-url";
|
|
||||||
import { nanoid } from "../../../utils/nanoid";
|
import { nanoid } from "../../../utils/nanoid";
|
||||||
import { createRouter } from "../../createRouter";
|
import { createRouter } from "../../createRouter";
|
||||||
|
|
||||||
|
@ -72,18 +71,18 @@ export const demo = createRouter().mutation("create", {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const homePageUrl = absoluteUrl();
|
|
||||||
|
|
||||||
await prisma.poll.create({
|
await prisma.poll.create({
|
||||||
data: {
|
data: {
|
||||||
urlId: await nanoid(),
|
id: await nanoid(),
|
||||||
title: "Lunch Meeting Demo",
|
title: "Lunch Meeting",
|
||||||
type: "date",
|
type: "date",
|
||||||
location: "Starbucks, 901 New York Avenue",
|
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",
|
authorName: "Johnny",
|
||||||
verified: true,
|
verified: true,
|
||||||
demo: true,
|
demo: true,
|
||||||
|
adminUrlId,
|
||||||
|
participantUrlId: await nanoid(),
|
||||||
user: {
|
user: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
where: {
|
where: {
|
||||||
|
@ -97,20 +96,6 @@ export const demo = createRouter().mutation("create", {
|
||||||
data: options,
|
data: options,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
links: {
|
|
||||||
createMany: {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
role: "admin",
|
|
||||||
urlId: adminUrlId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "participant",
|
|
||||||
urlId: await nanoid(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
participants: {
|
participants: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: participants,
|
data: participants,
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const participants = createRouter()
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
createdAt: "desc",
|
createdAt: "asc",
|
||||||
},
|
},
|
||||||
{ name: "desc" },
|
{ name: "desc" },
|
||||||
],
|
],
|
||||||
|
@ -48,7 +48,7 @@ export const participants = createRouter()
|
||||||
.mutation("add", {
|
.mutation("add", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
pollId: z.string(),
|
pollId: z.string(),
|
||||||
name: z.string(),
|
name: z.string().nonempty("Participant name is required"),
|
||||||
votes: z
|
votes: z
|
||||||
.object({
|
.object({
|
||||||
optionId: z.string(),
|
optionId: z.string(),
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const verification = createRouter()
|
||||||
|
|
||||||
const poll = await prisma.poll.update({
|
const poll = await prisma.poll.update({
|
||||||
where: {
|
where: {
|
||||||
urlId: pollId,
|
id: pollId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
verified: true,
|
verified: true,
|
||||||
|
@ -63,11 +63,10 @@ export const verification = createRouter()
|
||||||
resolve: async ({ input: { pollId, adminUrlId } }) => {
|
resolve: async ({ input: { pollId, adminUrlId } }) => {
|
||||||
const poll = await prisma.poll.findUnique({
|
const poll = await prisma.poll.findUnique({
|
||||||
where: {
|
where: {
|
||||||
urlId: pollId,
|
id: pollId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: true,
|
||||||
links: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -34,13 +34,9 @@ export const user = createRouter()
|
||||||
closed: true,
|
closed: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
links: {
|
adminUrlId: true,
|
||||||
where: {
|
|
||||||
role: "admin",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
take: 5,
|
take: 10,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "desc",
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Link } from "@prisma/client";
|
|
||||||
import * as Eta from "eta";
|
import * as Eta from "eta";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest } from "next";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import { prisma } from "~/prisma/db";
|
import { prisma } from "~/prisma/db";
|
||||||
|
@ -14,38 +13,6 @@ export const getQueryParam = (req: NextApiRequest, queryKey: string) => {
|
||||||
return typeof value === "string" ? value : value[0];
|
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 NotificationAction =
|
||||||
| {
|
| {
|
||||||
type: "newParticipant";
|
type: "newParticipant";
|
||||||
|
@ -62,8 +29,8 @@ export const sendNotification = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const poll = await prisma.poll.findUnique({
|
const poll = await prisma.poll.findUnique({
|
||||||
where: { urlId: pollId },
|
where: { id: pollId },
|
||||||
include: { user: true, links: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
* poll needs to:
|
* poll needs to:
|
||||||
|
@ -79,12 +46,8 @@ export const sendNotification = async (
|
||||||
!poll.demo &&
|
!poll.demo &&
|
||||||
poll.notifications
|
poll.notifications
|
||||||
) {
|
) {
|
||||||
const adminLink = getAdminLink(poll.links);
|
|
||||||
if (!adminLink) {
|
|
||||||
throw new Error(`Missing admin link for poll: ${pollId}`);
|
|
||||||
}
|
|
||||||
const homePageUrl = absoluteUrl();
|
const homePageUrl = absoluteUrl();
|
||||||
const pollUrl = `${homePageUrl}/admin/${adminLink.urlId}`;
|
const pollUrl = `${homePageUrl}/admin/${poll.adminUrlId}`;
|
||||||
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
|
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -127,9 +90,6 @@ export const sendNotification = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAdminLink = (links: Link[]) =>
|
|
||||||
links.find((link) => link.role === "admin");
|
|
||||||
|
|
||||||
interface SendEmailTemplateParams {
|
interface SendEmailTemplateParams {
|
||||||
templateName: string;
|
templateName: string;
|
||||||
to: 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[];
|
options: Option[];
|
||||||
user: User;
|
user: User;
|
||||||
role: Role;
|
timeZone: string | null;
|
||||||
links: Array<Link>;
|
adminUrlId: string;
|
||||||
pollId: 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,
|
body,
|
||||||
#__next {
|
#__next {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@apply bg-slate-50;
|
@apply overflow-x-hidden bg-slate-50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-50 text-base text-slate-600;
|
@apply bg-slate-50 text-base text-slate-600;
|
||||||
|
@ -32,13 +32,13 @@
|
||||||
@apply outline-none;
|
@apply outline-none;
|
||||||
}
|
}
|
||||||
a {
|
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 {
|
label {
|
||||||
@apply mb-1 block text-sm text-slate-800;
|
@apply mb-1 block text-sm text-slate-800;
|
||||||
}
|
}
|
||||||
button {
|
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 {
|
#floating-ui-root {
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
@apply mb-4;
|
@apply mb-4;
|
||||||
}
|
}
|
||||||
.input {
|
.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 {
|
input.input {
|
||||||
@apply h-9;
|
@apply h-9;
|
||||||
|
@ -63,17 +63,17 @@
|
||||||
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
|
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
|
||||||
}
|
}
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@apply 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 {
|
.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 {
|
a.btn {
|
||||||
@apply cursor-pointer hover:no-underline;
|
@apply cursor-pointer hover:no-underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-default {
|
.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 {
|
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;
|
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
|
||||||
}
|
}
|
||||||
.btn-link {
|
.btn-link {
|
||||||
@apply text-primary-500 inline-flex items-center underline;
|
@apply inline-flex items-center text-primary-500 underline;
|
||||||
}
|
}
|
||||||
.btn.btn-disabled {
|
.btn.btn-disabled {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
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 {
|
a.btn-primary {
|
||||||
|
@ -127,13 +127,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.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 {
|
@layer components {
|
||||||
.heading {
|
.heading {
|
||||||
@apply text-primary-500 text-xl;
|
@apply text-xl text-primary-500;
|
||||||
}
|
}
|
||||||
.subheading {
|
.subheading {
|
||||||
@apply mb-16 text-4xl font-bold text-slate-800;
|
@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 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='Manage'");
|
||||||
await page.click("text='Edit options'");
|
await page.click("text='Edit options'");
|
||||||
|
|
|
@ -16,61 +16,75 @@ test.beforeAll(async ({ request, baseURL }) => {
|
||||||
// Active Poll
|
// Active Poll
|
||||||
{
|
{
|
||||||
title: "Active Poll",
|
title: "Active Poll",
|
||||||
urlId: "active-poll",
|
id: "active-poll",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
|
participantUrlId: "p1",
|
||||||
|
adminUrlId: "a1",
|
||||||
},
|
},
|
||||||
// Poll that has been deleted 6 days ago
|
// Poll that has been deleted 6 days ago
|
||||||
{
|
{
|
||||||
title: "Deleted poll",
|
title: "Deleted poll",
|
||||||
urlId: "deleted-poll-6d",
|
id: "deleted-poll-6d",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
deleted: true,
|
deleted: true,
|
||||||
deletedAt: addDays(new Date(), -6),
|
deletedAt: addDays(new Date(), -6),
|
||||||
|
participantUrlId: "p2",
|
||||||
|
adminUrlId: "a2",
|
||||||
},
|
},
|
||||||
// Poll that has been deleted 7 days ago
|
// Poll that has been deleted 7 days ago
|
||||||
{
|
{
|
||||||
title: "Deleted poll 7d",
|
title: "Deleted poll 7d",
|
||||||
urlId: "deleted-poll-7d",
|
id: "deleted-poll-7d",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
deleted: true,
|
deleted: true,
|
||||||
deletedAt: addDays(new Date(), -7),
|
deletedAt: addDays(new Date(), -7),
|
||||||
|
participantUrlId: "p3",
|
||||||
|
adminUrlId: "a3",
|
||||||
},
|
},
|
||||||
// Poll that has been inactive for 29 days
|
// Poll that has been inactive for 29 days
|
||||||
{
|
{
|
||||||
title: "Still active",
|
title: "Still active",
|
||||||
urlId: "still-active-poll",
|
id: "still-active-poll",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
touchedAt: addDays(new Date(), -29),
|
touchedAt: addDays(new Date(), -29),
|
||||||
|
participantUrlId: "p4",
|
||||||
|
adminUrlId: "a4",
|
||||||
},
|
},
|
||||||
// Poll that has been inactive for 30 days
|
// Poll that has been inactive for 30 days
|
||||||
{
|
{
|
||||||
title: "Inactive poll",
|
title: "Inactive poll",
|
||||||
urlId: "inactive-poll",
|
id: "inactive-poll",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
touchedAt: addDays(new Date(), -30),
|
touchedAt: addDays(new Date(), -30),
|
||||||
|
participantUrlId: "p5",
|
||||||
|
adminUrlId: "a5",
|
||||||
},
|
},
|
||||||
// Demo poll
|
// Demo poll
|
||||||
{
|
{
|
||||||
demo: true,
|
demo: true,
|
||||||
title: "Demo poll",
|
title: "Demo poll",
|
||||||
urlId: "demo-poll-new",
|
id: "demo-poll-new",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
participantUrlId: "p6",
|
||||||
|
adminUrlId: "a6",
|
||||||
},
|
},
|
||||||
// Old demo poll
|
// Old demo poll
|
||||||
{
|
{
|
||||||
demo: true,
|
demo: true,
|
||||||
title: "Demo poll",
|
title: "Demo poll",
|
||||||
urlId: "demo-poll-old",
|
id: "demo-poll-old",
|
||||||
type: "date",
|
type: "date",
|
||||||
userId: "user1",
|
userId: "user1",
|
||||||
createdAt: addDays(new Date(), -2),
|
createdAt: addDays(new Date(), -2),
|
||||||
|
participantUrlId: "p7",
|
||||||
|
adminUrlId: "a7",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -138,7 +152,7 @@ test.beforeAll(async ({ request, baseURL }) => {
|
||||||
test("should keep active polls", async () => {
|
test("should keep active polls", async () => {
|
||||||
const poll = await prisma.poll.findUnique({
|
const poll = await prisma.poll.findUnique({
|
||||||
where: {
|
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 () => {
|
test("should keep polls that have been soft deleted for less than 7 days", async () => {
|
||||||
const deletedPoll6d = await prisma.poll.findFirst({
|
const deletedPoll6d = await prisma.poll.findFirst({
|
||||||
where: {
|
where: {
|
||||||
urlId: "deleted-poll-6d",
|
id: "deleted-poll-6d",
|
||||||
deleted: true,
|
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 () => {
|
test("should hard delete polls that have been soft deleted for 7 days", async () => {
|
||||||
const deletedPoll7d = await prisma.poll.findFirst({
|
const deletedPoll7d = await prisma.poll.findFirst({
|
||||||
where: {
|
where: {
|
||||||
urlId: "deleted-poll-7d",
|
id: "deleted-poll-7d",
|
||||||
deleted: true,
|
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 () => {
|
test("should keep polls that are still active", async () => {
|
||||||
const stillActivePoll = await prisma.poll.findUnique({
|
const stillActivePoll = await prisma.poll.findUnique({
|
||||||
where: {
|
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 () => {
|
test("should soft delete polls that are inactive", async () => {
|
||||||
const inactivePoll = await prisma.poll.findFirst({
|
const inactivePoll = await prisma.poll.findFirst({
|
||||||
where: {
|
where: {
|
||||||
urlId: "inactive-poll",
|
id: "inactive-poll",
|
||||||
deleted: true,
|
deleted: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -221,7 +235,7 @@ test("should soft delete polls that are inactive", async () => {
|
||||||
test("should keep new demo poll", async () => {
|
test("should keep new demo poll", async () => {
|
||||||
const demoPoll = await prisma.poll.findFirst({
|
const demoPoll = await prisma.poll.findFirst({
|
||||||
where: {
|
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 () => {
|
test("should delete old demo poll", async () => {
|
||||||
const oldDemoPoll = await prisma.poll.findFirst({
|
const oldDemoPoll = await prisma.poll.findFirst({
|
||||||
where: {
|
where: {
|
||||||
urlId: "demo-poll-old",
|
id: "demo-poll-old",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -240,7 +254,7 @@ test("should delete old demo poll", async () => {
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
test.afterAll(async () => {
|
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",
|
"active-poll",
|
||||||
"deleted-poll-6d",
|
"deleted-poll-6d",
|
||||||
"deleted-poll-7d",
|
"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.setViewportSize({ width: 375, height: 667 });
|
||||||
await page.goto("/demo");
|
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("text='New'");
|
||||||
await page.click("data-testid=poll-option >> nth=0");
|
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 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");
|
await page.type('[placeholder="Your name"]', "Test user");
|
||||||
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
|
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
|
||||||
|
@ -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='Test user'")).toBeVisible();
|
||||||
await expect(page.locator("text=Guest")).toBeVisible();
|
await expect(page.locator("text=Guest")).toBeVisible();
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
await page.type(
|
await page.type(
|
||||||
"[placeholder='Thanks for the invite!']",
|
"[placeholder='Thanks for the invite!']",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue