diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index d83a7a0d1..2bff03642 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -155,7 +155,6 @@ "goToInvite": "Go to Invite Page", "planPro": "Pro", "Billing": "Billing", - "planUpgrade": "Upgrade", "subscriptionUpdatePayment": "Update Payment Details", "subscriptionCancel": "Cancel Subscription", "billingStatus": "Billing Status", @@ -183,15 +182,9 @@ "selectPotentialDates": "Select potential dates or times for your event", "optionalLabel": "(Optional)", "calendarHelp": "You can't create a poll without any options. Add at least one option to continue.", - "hideParticipants": "Hide participant list", - "hideParticipantsDescription": "Keep participant details hidden from other participants", "editSettings": "Edit settings", - "pollSettingsDescription": "Customize the behaviour of your poll", "commentPlaceholder": "Leave a comment on this poll (visible to everyone)", - "hideScores": "Hide scores", - "hideScoresDescription": "Only show scores until after a participant has voted", "disableComments": "Disable comments", - "disableCommentsDescription": "Remove the option to leave a comment on the poll", "clockPreferences": "Clock Preferences", "clockPreferencesDescription": "Set your preferred time zone and time format.", "featureRequest": "Request a Feature", @@ -231,5 +224,10 @@ "features": "Get access to all current and future Pro features!", "noAds": "No ads", "supportProject": "Support this project", - "pricing": "Pricing" + "pricing": "Pricing", + "pleaseUpgrade": "Please upgrade to Pro to use this feature", + "pollSettingsDescription": "Customize the behaviour of your poll", + "requireParticipantEmailLabel": "Make email address required for participants", + "hideParticipantsLabel": "Hide participant list from other participants", + "hideScoresLabel": "Hide scores until after a participant has voted" } diff --git a/apps/web/src/components/create-poll.tsx b/apps/web/src/components/create-poll.tsx index 885b5a941..4dd612f7e 100644 --- a/apps/web/src/components/create-poll.tsx +++ b/apps/web/src/components/create-poll.tsx @@ -39,6 +39,9 @@ export const CreatePoll: React.FunctionComponent = () => { const form = useForm({ defaultValues: { + title: "", + description: "", + location: "", view: "month", options: [], hideScores: false, @@ -73,6 +76,7 @@ export const CreatePoll: React.FunctionComponent = () => { hideParticipants: formData?.hideParticipants, disableComments: formData?.disableComments, hideScores: formData?.hideScores, + requireParticipantEmail: formData?.requireParticipantEmail, options: required(formData?.options).map((option) => ({ startDate: option.type === "date" ? option.date : option.start, endDate: option.type === "timeSlot" ? option.end : undefined, @@ -115,7 +119,9 @@ export const CreatePoll: React.FunctionComponent = () => {
+ -
{t("newParticipantFormDescription")}
- { - const newParticipant = await addParticipant.mutateAsync({ - name: data.name, - votes: props.votes, - email: data.email, - pollId: poll.id, - }); - props.onSubmit?.(newParticipant); - })} - className="space-y-4" - > -
- - - {formState.errors.name?.message ? ( -
- {formState.errors.name.message} -
- ) : null} -
-
- - { - if (!value) return true; - return validEmail(value); - }, - })} - /> - {formState.errors.email?.message ? ( -
- {formState.errors.email.message} -
- ) : null} -
-
- - -
-
- - -
- - + ); }; - -export const useNewParticipantModal = () => { - const modalContext = useModalContext(); - - const showNewParticipantModal = (props: NewParticipantModalProps) => { - return modalContext.render({ - showClose: true, - overlayClosable: true, - content: function Content({ close }) { - return ( - { - props.onSubmit?.(data); - close(); - }} - onCancel={close} - /> - ); - }, - footer: null, - }); - }; - - return showNewParticipantModal; -}; diff --git a/apps/web/src/components/poll.tsx b/apps/web/src/components/poll.tsx index 209a2022c..98ab50043 100644 --- a/apps/web/src/components/poll.tsx +++ b/apps/web/src/components/poll.tsx @@ -6,6 +6,7 @@ import Discussion from "@/components/discussion"; import { EventCard } from "@/components/event-card"; import DesktopPoll from "@/components/poll/desktop-poll"; import MobilePoll from "@/components/poll/mobile-poll"; +import { VotingForm } from "@/components/poll/voting-form"; import { usePoll } from "@/contexts/poll"; import { useTouchBeacon } from "./poll/use-touch-beacon"; @@ -34,7 +35,9 @@ export const Poll = () => {
- + + + {poll.disableComments ? null : ( <> diff --git a/apps/web/src/components/poll/desktop-poll.tsx b/apps/web/src/components/poll/desktop-poll.tsx index 6d30b6083..42a6d6dc9 100644 --- a/apps/web/src/components/poll/desktop-poll.tsx +++ b/apps/web/src/components/poll/desktop-poll.tsx @@ -15,7 +15,7 @@ import { RemoveScroll } from "react-remove-scroll"; import { useMeasure, useScroll } from "react-use"; import { TimesShownIn } from "@/components/clock"; -import { useVotingForm, VotingForm } from "@/components/poll/voting-form"; +import { useVotingForm } from "@/components/poll/voting-form"; import { usePermissions } from "@/contexts/permissions"; import { @@ -266,8 +266,9 @@ const DesktopPoll: React.FunctionComponent = () => { key={i} participant={participant} editMode={ + votingForm.watch("mode") === "edit" && votingForm.watch("participantId") === - participant.id + participant.id } onChangeEditMode={(isEditing) => { if (isEditing) { @@ -322,12 +323,4 @@ const DesktopPoll: React.FunctionComponent = () => { ); }; -const WrappedDesktopPoll = () => { - return ( - - - - ); -}; - -export default WrappedDesktopPoll; +export default DesktopPoll; diff --git a/apps/web/src/components/poll/manage-poll.tsx b/apps/web/src/components/poll/manage-poll.tsx index c44337d5b..f551be603 100644 --- a/apps/web/src/components/poll/manage-poll.tsx +++ b/apps/web/src/components/poll/manage-poll.tsx @@ -65,7 +65,6 @@ const ManagePoll: React.FunctionComponent<{ - diff --git a/apps/web/src/components/poll/mobile-poll.tsx b/apps/web/src/components/poll/mobile-poll.tsx index f7af0c317..4d2ae9a3c 100644 --- a/apps/web/src/components/poll/mobile-poll.tsx +++ b/apps/web/src/components/poll/mobile-poll.tsx @@ -4,25 +4,21 @@ import { Button } from "@rallly/ui/button"; import { AnimatePresence, m } from "framer-motion"; import { useTranslation } from "next-i18next"; import * as React from "react"; -import { FormProvider, useForm } from "react-hook-form"; -import { useUpdateEffect } from "react-use"; import smoothscroll from "smoothscroll-polyfill"; import { TimesShownIn } from "@/components/clock"; import { ParticipantDropdown } from "@/components/participant-dropdown"; +import { useVotingForm } from "@/components/poll/voting-form"; import { useOptions, usePoll } from "@/components/poll-context"; import { usePermissions } from "@/contexts/permissions"; import { styleMenuItem } from "../menu-styles"; -import { useNewParticipantModal } from "../new-participant-modal"; import { useParticipants, useVisibleParticipants, } from "../participants-provider"; import { useUser } from "../user-provider"; import GroupedOptions from "./mobile-poll/grouped-options"; -import { normalizeVotes, useUpdateParticipantMutation } from "./mutations"; -import { ParticipantForm } from "./types"; import UserAvatar, { YouAvatar } from "./user-avatar"; if (typeof window !== "undefined") { @@ -32,28 +28,17 @@ if (typeof window !== "undefined") { const MobilePoll: React.FunctionComponent = () => { const pollContext = usePoll(); - const { poll, admin, getParticipantById, optionIds, getVote } = pollContext; + const { poll, getParticipantById } = pollContext; const { options } = useOptions(); const { participants } = useParticipants(); const session = useUser(); - const form = useForm({ - defaultValues: { - votes: [], - }, - }); + const votingForm = useVotingForm(); + const { formState } = votingForm; - const { reset, handleSubmit, formState } = form; - const [selectedParticipantId, setSelectedParticipantId] = React.useState< - string | undefined - >(() => { - if (!admin) { - const participant = participants.find((p) => session.ownsObject(p)); - return participant?.id; - } - }); + const selectedParticipantId = votingForm.watch("participantId"); const visibleParticipants = useVisibleParticipants(); const selectedParticipant = selectedParticipantId @@ -62,199 +47,158 @@ const MobilePoll: React.FunctionComponent = () => { const { canEditParticipant, canAddNewParticipant } = usePermissions(); - const [isEditing, setIsEditing] = React.useState( - canAddNewParticipant && !participants.some((p) => canEditParticipant(p.id)), - ); - - useUpdateEffect(() => { - if (!canAddNewParticipant) { - setIsEditing(false); - } - }, [canAddNewParticipant]); - - const formRef = React.useRef(null); - const { t } = useTranslation(); - const updateParticipant = useUpdateParticipantMutation(); - - const showNewParticipantModal = useNewParticipantModal(); + const isEditing = votingForm.watch("mode") !== "view"; return ( - -
{ - if (selectedParticipant) { - await updateParticipant.mutateAsync({ - pollId: poll.id, - participantId: selectedParticipant.id, - votes: normalizeVotes(optionIds, votes), - }); - setIsEditing(false); - } else { - showNewParticipantModal({ - votes: normalizeVotes(optionIds, votes), - onSubmit: async ({ id }) => { - setSelectedParticipantId(id); - setIsEditing(false); - }, - }); - } - })} - > -
-
- {selectedParticipantId || !isEditing ? ( - { - setSelectedParticipantId(participantId); - }} - disabled={isEditing} - > -
- -
- {selectedParticipant ? ( -
- -
- ) : ( - t("participantCount", { count: participants.length }) - )} -
- -
- - - {t("participantCount", { count: participants.length })} - - {visibleParticipants.map((participant) => ( - -
- -
-
- ))} -
-
-
- ) : ( -
- -
- )} - {isEditing ? ( - - ) : selectedParticipant ? ( - { - setIsEditing(true); - reset({ - votes: optionIds.map((optionId) => ({ - optionId, - type: getVote(selectedParticipant.id, optionId), - })), - }); - }} - > -
-
- {poll.options[0].duration !== 0 ? ( -
- -
- ) : null} - { - if (option.type === "timeSlot") { - return `${option.dow} ${option.day} ${option.month}`; - } - return `${option.month} ${option.year}`; - }} - /> - - {isEditing ? ( - +
+
+ {selectedParticipantId || !isEditing ? ( + { + votingForm.setValue("participantId", participantId); }} - initial="hidden" - animate="visible" - exit={{ - opacity: 0, - y: -10, - height: 0, - transition: { duration: 0.2 }, + disabled={isEditing} + > +
+ +
+ {selectedParticipant ? ( +
+ +
+ ) : ( + t("participantCount", { count: participants.length }) + )} +
+ +
+ + + {t("participantCount", { count: participants.length })} + + {visibleParticipants.map((participant) => ( + +
+ +
+
+ ))} +
+
+
+ ) : ( +
+ +
+ )} + {isEditing ? ( + -
- + {t("cancel")} + + ) : selectedParticipant ? ( + { + votingForm.setEditingParticipantId(selectedParticipant.id); + }} + > +
+
+ {poll.options[0].duration !== 0 ? ( +
+ +
+ ) : null} + { + if (option.type === "timeSlot") { + return `${option.dow} ${option.day} ${option.month}`; + } + return `${option.month} ${option.year}`; + }} + /> + + {isEditing ? ( + +
+ +
+
+ ) : null} +
+ ); }; diff --git a/apps/web/src/components/poll/mobile-poll/poll-options.tsx b/apps/web/src/components/poll/mobile-poll/poll-options.tsx index 6819823df..a65916b42 100644 --- a/apps/web/src/components/poll/mobile-poll/poll-options.tsx +++ b/apps/web/src/components/poll/mobile-poll/poll-options.tsx @@ -1,11 +1,11 @@ import { VoteType } from "@rallly/database"; import * as React from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { useVotingForm } from "@/components/poll/voting-form"; import { usePoll } from "@/components/poll-context"; import { ParsedDateTimeOpton } from "@/utils/date-time-utils"; -import { ParticipantForm } from "../types"; import DateOption from "./date-option"; import TimeSlotOption from "./time-slot-option"; @@ -20,7 +20,7 @@ const PollOptions: React.FunctionComponent = ({ editable, selectedParticipantId, }) => { - const { control } = useFormContext(); + const { control } = useVotingForm(); const { getParticipantsWhoVotedForOption, getParticipantById, diff --git a/apps/web/src/components/poll/voting-form.tsx b/apps/web/src/components/poll/voting-form.tsx index aafc0154c..ee8402846 100644 --- a/apps/web/src/components/poll/voting-form.tsx +++ b/apps/web/src/components/poll/voting-form.tsx @@ -1,13 +1,21 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@rallly/ui/dialog"; import React from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { z } from "zod"; -import { useNewParticipantModal } from "@/components/new-participant-modal"; +import { NewParticipantForm } from "@/components/new-participant-modal"; import { useParticipants } from "@/components/participants-provider"; import { normalizeVotes, useUpdateParticipantMutation, } from "@/components/poll/mutations"; +import { Trans } from "@/components/trans"; import { usePermissions } from "@/contexts/permissions"; import { usePoll } from "@/contexts/poll"; import { useRole } from "@/contexts/role"; @@ -70,7 +78,6 @@ export const useVotingForm = () => { export const VotingForm = ({ children }: React.PropsWithChildren) => { const { id: pollId, options } = usePoll(); - const showNewParticipantModal = useNewParticipantModal(); const updateParticipant = useUpdateParticipantMutation(); const { participants } = useParticipants(); @@ -80,6 +87,10 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => { ); const role = useRole(); + const optionIds = options.map((option) => option.id); + + const [isNewParticipantModalOpen, setIsNewParticipantModalOpen] = + React.useState(false); const form = useForm({ defaultValues: { @@ -87,6 +98,10 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => { canAddNewParticipant && !userAlreadyVoted && role === "participant" ? "new" : "view", + participantId: + role === "participant" + ? participants.find((p) => canEditParticipant(p.id))?.id + : undefined, votes: options.map((option) => ({ optionId: option.id, })), @@ -98,8 +113,6 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
{ - const optionIds = options.map((option) => option.id); - if (data.participantId) { // update participant @@ -110,6 +123,7 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => { }); form.reset({ + mode: "view", participantId: undefined, votes: options.map((option) => ({ optionId: option.id, @@ -117,21 +131,39 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => { }); } else { // new participant - showNewParticipantModal({ - votes: normalizeVotes(optionIds, data.votes), - onSubmit: async () => { - form.reset({ - mode: "view", - participantId: undefined, - votes: options.map((option) => ({ - optionId: option.id, - })), - }); - }, - }); + setIsNewParticipantModalOpen(true); } })} /> + + + + + + + + + + + { + form.reset({ + mode: "view", + participantId: newParticipant.id, + votes: options.map((option) => ({ + optionId: option.id, + })), + }); + setIsNewParticipantModalOpen(false); + }} + onCancel={() => setIsNewParticipantModalOpen(false)} + /> + + {children} ); diff --git a/apps/web/src/components/pro-badge.tsx b/apps/web/src/components/pro-badge.tsx index 9ba3c026a..44d6d0c20 100644 --- a/apps/web/src/components/pro-badge.tsx +++ b/apps/web/src/components/pro-badge.tsx @@ -1,13 +1,36 @@ import { Badge } from "@rallly/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; +import { useRouter } from "next/router"; +import { Trans } from "next-i18next"; import { usePlan } from "@/contexts/plan"; export const ProBadge = ({ className }: { className?: string }) => { const isPaid = usePlan() === "paid"; - + const router = useRouter(); if (isPaid) { return null; } - return Pro; + return ( + + { + router.push("/settings/billing"); + }} + > + + + + + + + + + ); }; diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index d49d858e3..6e4f71357 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -8,7 +8,7 @@ const supportedLocales = Object.keys(languages); // these paths are always public const publicPaths = ["/login", "/register", "/invite", "/auth"]; // these paths always require authentication -const protectedPaths = ["/settings/billing", "/settings/profile"]; +const protectedPaths = ["/settings/profile"]; const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => { const session = await getSession(req, res); diff --git a/apps/web/src/pages/poll/[urlId]/edit-settings.tsx b/apps/web/src/pages/poll/[urlId]/edit-settings.tsx index 7290af439..a56388169 100644 --- a/apps/web/src/pages/poll/[urlId]/edit-settings.tsx +++ b/apps/web/src/pages/poll/[urlId]/edit-settings.tsx @@ -10,7 +10,6 @@ import { PollSettingsFormData, } from "@/components/forms/poll-settings"; import { getPollLayout } from "@/components/layouts/poll-layout"; -import { PayWall } from "@/components/pay-wall"; import { useUpdatePollMutation } from "@/components/poll/mutations"; import { Trans } from "@/components/trans"; import { usePoll } from "@/contexts/poll"; @@ -35,39 +34,38 @@ const Page: NextPageWithLayout = () => { hideParticipants: poll.hideParticipants, hideScores: poll.hideScores, disableComments: poll.disableComments, + requireParticipantEmail: poll.requireParticipantEmail, }, }); return ( - - - { - //submit - await update.mutateAsync( - { urlId: poll.adminUrlId, ...data }, - { - onSuccess: redirectBackToPoll, - }, - ); - })} - > - - - - - - - - -
+
+ { + //submit + await update.mutateAsync( + { urlId: poll.adminUrlId, ...data }, + { + onSuccess: redirectBackToPoll, + }, + ); + })} + > + + + + + + +
+ ); }; diff --git a/packages/backend/trpc/routers/polls.ts b/packages/backend/trpc/routers/polls.ts index 823faa01c..41bc0cb9d 100644 --- a/packages/backend/trpc/routers/polls.ts +++ b/packages/backend/trpc/routers/polls.ts @@ -58,6 +58,7 @@ export const polls = router({ hideParticipants: z.boolean().optional(), hideScores: z.boolean().optional(), disableComments: z.boolean().optional(), + requireParticipantEmail: z.boolean().optional(), options: z .object({ startDate: z.string(), @@ -116,6 +117,7 @@ export const polls = router({ hideParticipants: input.hideParticipants, disableComments: input.disableComments, hideScores: input.hideScores, + requireParticipantEmail: input.requireParticipantEmail, }, }); @@ -161,6 +163,7 @@ export const polls = router({ hideParticipants: z.boolean().optional(), disableComments: z.boolean().optional(), hideScores: z.boolean().optional(), + requireParticipantEmail: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -211,6 +214,7 @@ export const polls = router({ hideScores: input.hideScores, hideParticipants: input.hideParticipants, disableComments: input.disableComments, + requireParticipantEmail: input.requireParticipantEmail, }, }); }), @@ -377,6 +381,7 @@ export const polls = router({ hideParticipants: true, disableComments: true, hideScores: true, + requireParticipantEmail: true, demo: true, options: { select: { diff --git a/packages/database/prisma/migrations/20230915170216_add_required_email/migration.sql b/packages/database/prisma/migrations/20230915170216_add_required_email/migration.sql new file mode 100644 index 000000000..53f0a8fbf --- /dev/null +++ b/packages/database/prisma/migrations/20230915170216_add_required_email/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "polls" ADD COLUMN "require_participant_email" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7c3671534..9f8cce0d7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -89,34 +89,35 @@ enum ParticipantVisibility { } model Poll { - id String @id @unique @map("id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deadline DateTime? - title String - description String? - location String? - user User? @relation(fields: [userId], references: [id]) - userId String @map("user_id") - votes Vote[] - timeZone String? @map("time_zone") - options Option[] - participants Participant[] - watchers Watcher[] - demo Boolean @default(false) - comments Comment[] - legacy Boolean @default(false) // @deprecated - closed Boolean @default(false) // we use this to indicate whether a poll is paused - deleted Boolean @default(false) - deletedAt DateTime? @map("deleted_at") - touchedAt DateTime @default(now()) @map("touched_at") - participantUrlId String @unique @map("participant_url_id") - adminUrlId String @unique @map("admin_url_id") - eventId String? @map("event_id") - event Event? - hideParticipants Boolean @default(false) @map("hide_participants") - hideScores Boolean @default(false) @map("hide_scores") - disableComments Boolean @default(false) @map("disable_comments") + id String @id @unique @map("id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deadline DateTime? + title String + description String? + location String? + user User? @relation(fields: [userId], references: [id]) + userId String @map("user_id") + votes Vote[] + timeZone String? @map("time_zone") + options Option[] + participants Participant[] + watchers Watcher[] + demo Boolean @default(false) + comments Comment[] + legacy Boolean @default(false) // @deprecated + closed Boolean @default(false) // we use this to indicate whether a poll is paused + deleted Boolean @default(false) + deletedAt DateTime? @map("deleted_at") + touchedAt DateTime @default(now()) @map("touched_at") + participantUrlId String @unique @map("participant_url_id") + adminUrlId String @unique @map("admin_url_id") + eventId String? @map("event_id") + event Event? + hideParticipants Boolean @default(false) @map("hide_participants") + hideScores Boolean @default(false) @map("hide_scores") + disableComments Boolean @default(false) @map("disable_comments") + requireParticipantEmail Boolean @default(false) @map("require_participant_email") @@index([userId], type: Hash) @@map("polls")