Allow making email required (#864)

This commit is contained in:
Luke Vella 2023-09-18 10:12:21 +01:00 committed by GitHub
parent b9d4b31f38
commit a9253bd972
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 516 additions and 495 deletions

View file

@ -155,7 +155,6 @@
"goToInvite": "Go to Invite Page", "goToInvite": "Go to Invite Page",
"planPro": "Pro", "planPro": "Pro",
"Billing": "Billing", "Billing": "Billing",
"planUpgrade": "Upgrade",
"subscriptionUpdatePayment": "Update Payment Details", "subscriptionUpdatePayment": "Update Payment Details",
"subscriptionCancel": "Cancel Subscription", "subscriptionCancel": "Cancel Subscription",
"billingStatus": "Billing Status", "billingStatus": "Billing Status",
@ -183,15 +182,9 @@
"selectPotentialDates": "Select potential dates or times for your event", "selectPotentialDates": "Select potential dates or times for your event",
"optionalLabel": "(Optional)", "optionalLabel": "(Optional)",
"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.",
"hideParticipants": "Hide participant list",
"hideParticipantsDescription": "Keep participant details hidden from other participants",
"editSettings": "Edit settings", "editSettings": "Edit settings",
"pollSettingsDescription": "Customize the behaviour of your poll",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)", "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", "disableComments": "Disable comments",
"disableCommentsDescription": "Remove the option to leave a comment on the poll",
"clockPreferences": "Clock Preferences", "clockPreferences": "Clock Preferences",
"clockPreferencesDescription": "Set your preferred time zone and time format.", "clockPreferencesDescription": "Set your preferred time zone and time format.",
"featureRequest": "Request a Feature", "featureRequest": "Request a Feature",
@ -231,5 +224,10 @@
"features": "Get access to all current and future Pro features!", "features": "Get access to all current and future Pro features!",
"noAds": "No ads", "noAds": "No ads",
"supportProject": "Support this project", "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"
} }

View file

@ -39,6 +39,9 @@ export const CreatePoll: React.FunctionComponent = () => {
const form = useForm<NewEventData>({ const form = useForm<NewEventData>({
defaultValues: { defaultValues: {
title: "",
description: "",
location: "",
view: "month", view: "month",
options: [], options: [],
hideScores: false, hideScores: false,
@ -73,6 +76,7 @@ export const CreatePoll: React.FunctionComponent = () => {
hideParticipants: formData?.hideParticipants, hideParticipants: formData?.hideParticipants,
disableComments: formData?.disableComments, disableComments: formData?.disableComments,
hideScores: formData?.hideScores, hideScores: formData?.hideScores,
requireParticipantEmail: formData?.requireParticipantEmail,
options: required(formData?.options).map((option) => ({ options: required(formData?.options).map((option) => ({
startDate: option.type === "date" ? option.date : option.start, startDate: option.type === "date" ? option.date : option.start,
endDate: option.type === "timeSlot" ? option.end : undefined, endDate: option.type === "timeSlot" ? option.end : undefined,
@ -115,7 +119,9 @@ export const CreatePoll: React.FunctionComponent = () => {
<PollSettingsForm /> <PollSettingsForm />
<hr /> <hr />
<Button <Button
loading={form.formState.isSubmitting} loading={
form.formState.isSubmitting || form.formState.isSubmitSuccessful
}
size="lg" size="lg"
type="submit" type="submit"
className="w-full" className="w-full"

View file

@ -1,4 +1,10 @@
import { EyeIcon, MessageCircleIcon, VoteIcon } from "@rallly/icons"; import {
AtSignIcon,
EyeIcon,
InfoIcon,
MessageCircleIcon,
VoteIcon,
} from "@rallly/icons";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { import {
Card, Card,
@ -7,10 +13,9 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@rallly/ui/card"; } from "@rallly/ui/card";
import { FormField, FormItem } from "@rallly/ui/form"; import { FormField } from "@rallly/ui/form";
import { Label } from "@rallly/ui/label";
import { Switch } from "@rallly/ui/switch"; import { Switch } from "@rallly/ui/switch";
import Link from "next/link"; import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import React from "react"; import React from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
@ -19,26 +24,57 @@ import { ProBadge } from "@/components/pro-badge";
import { usePlan } from "@/contexts/plan"; import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = { export type PollSettingsFormData = {
requireParticipantEmail: boolean;
hideParticipants: boolean; hideParticipants: boolean;
hideScores: boolean; hideScores: boolean;
disableComments: boolean; disableComments: boolean;
}; };
const SettingContent = ({ children }: React.PropsWithChildren) => { const SettingContent = ({ children }: React.PropsWithChildren) => {
return <div className="grid grow gap-1.5 pt-0.5">{children}</div>; return <div className="grid grow pt-0.5">{children}</div>;
}; };
const SettingDescription = ({ children }: React.PropsWithChildren) => { const SettingTitle = ({
return <p className="text-muted-foreground text-sm">{children}</p>; children,
}; pro,
hint,
const SettingTitle = Label; }: React.PropsWithChildren<{
pro?: boolean;
const Setting = ({ children }: React.PropsWithChildren) => { htmlFor?: string;
hint?: React.ReactNode;
}>) => {
return ( return (
<FormItem className="rounded-lg border p-4"> <div className="flex min-w-0 items-center gap-x-2.5">
<div className="flex items-start justify-between gap-x-4">{children}</div> <div className="text-sm font-medium">{children}</div>
</FormItem> {pro ? <ProBadge /> : null}
{hint ? (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="text-muted-foreground h-4 w-4" />
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
) : null}
</div>
);
};
const Setting = ({
children,
disabled,
}: React.PropsWithChildren<{ disabled?: boolean }>) => {
const Component = disabled ? "div" : "label";
return (
<Component
className={cn(
disabled
? "bg-muted-background text-muted-foreground"
: "cursor-pointer bg-white hover:bg-gray-50 active:bg-gray-100",
"flex select-none justify-between gap-x-4 gap-y-2.5 rounded-md border p-3 sm:flex-row ",
)}
>
{children}
</Component>
); );
}; };
@ -47,7 +83,7 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
const plan = usePlan(); const plan = usePlan();
const disabled = plan === "free"; const isFree = plan === "free";
return ( return (
<Card> <Card>
@ -58,7 +94,6 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
<CardTitle> <CardTitle>
<Trans i18nKey="settings" /> <Trans i18nKey="settings" />
</CardTitle> </CardTitle>
<ProBadge />
</div> </div>
<CardDescription> <CardDescription>
<Trans <Trans
@ -67,43 +102,71 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
/> />
</CardDescription> </CardDescription>
</div> </div>
{disabled ? (
<div>
<Link className="text-link text-sm" href="/settings/billing">
<Trans i18nKey="planUpgrade" />
</Link>
</div>
) : null}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <div className={cn("grid gap-2.5")}>
className={cn( <FormField
"grid gap-2.5", control={form.control}
disabled ? "pointer-events-none opacity-50" : "", name="disableComments"
)} render={({ field }) => (
> <Setting>
<MessageCircleIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent>
<SettingTitle htmlFor="disableComments">
<Trans i18nKey="disableComments">Disable Comments</Trans>
</SettingTitle>
</SettingContent>
<Switch
id={field.name}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
<FormField
control={form.control}
name="requireParticipantEmail"
render={({ field }) => (
<Setting disabled={isFree}>
<AtSignIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent>
<SettingTitle pro>
<Trans
i18nKey="requireParticipantEmailLabel"
defaults="Make email address required for participants"
/>
</SettingTitle>
</SettingContent>
<Switch
disabled={isFree}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="hideParticipants" name="hideParticipants"
render={({ field }) => ( render={({ field }) => (
<Setting> <Setting disabled={isFree}>
<EyeIcon className="h-6 w-6" /> <EyeIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent> <SettingContent>
<SettingTitle> <SettingTitle pro>
<Trans i18nKey="hideParticipants">
Hide participant list
</Trans>
</SettingTitle>
<SettingDescription>
<Trans <Trans
i18nKey="hideParticipantsDescription" i18nKey="hideParticipantsLabel"
defaults="Keep participant details private" defaults="Hide participants from each other"
/> />
</SettingDescription> </SettingTitle>
</SettingContent> </SettingContent>
<Switch <Switch
disabled={disabled} disabled={isFree}
checked={field.value} checked={field.value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
field.onChange(checked); field.onChange(checked);
@ -116,48 +179,19 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
control={form.control} control={form.control}
name="hideScores" name="hideScores"
render={({ field }) => ( render={({ field }) => (
<Setting> <Setting disabled={isFree}>
<VoteIcon className="h-6 w-6" /> <VoteIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent> <SettingContent>
<SettingTitle> <SettingTitle htmlFor={field.name} pro>
<Trans i18nKey="hideScores">Hide scores</Trans>
</SettingTitle>
<SettingDescription>
<Trans <Trans
i18nKey="hideScoresDescription" i18nKey="hideScoresLabel"
defaults="Reduce bias by hiding the current vote counts from participants" defaults="Hide scores until after a participant has voted"
/> />
</SettingDescription> </SettingTitle>
</SettingContent> </SettingContent>
<Switch <Switch
disabled={disabled} id={field.name}
checked={field.value} disabled={isFree}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
<FormField
control={form.control}
name="disableComments"
render={({ field }) => (
<Setting>
<MessageCircleIcon className="h-6 w-6" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="disableComments">Disable comments</Trans>
</SettingTitle>
<SettingDescription>
<Trans
i18nKey="disableCommentsDescription"
defaults="Remove the option to leave a comment on the poll"
/>
</SettingDescription>
</SettingContent>
<Switch
disabled={disabled}
checked={field.value} checked={field.value}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
field.onChange(checked); field.onChange(checked);

View file

@ -1,21 +1,33 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { VoteType } from "@rallly/database"; import { VoteType } from "@rallly/database";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import clsx from "clsx"; import clsx from "clsx";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useMount } from "react-use"; import { useMount } from "react-use";
import z from "zod";
import { usePoll } from "@/contexts/poll";
import { useFormValidation } from "../utils/form-validation";
import { useModalContext } from "./modal/modal-provider";
import { useAddParticipantMutation } from "./poll/mutations"; import { useAddParticipantMutation } from "./poll/mutations";
import VoteIcon from "./poll/vote-icon"; import VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import { TextInput } from "./text-input"; import { TextInput } from "./text-input";
interface NewParticipantFormData { const requiredEmailSchema = z.object({
name: string; requireEmail: z.literal(true),
email?: string; name: z.string().min(1),
} email: z.string().email(),
});
const optionalEmailSchema = z.object({
requireEmail: z.literal(false),
name: z.string().min(1),
email: z.string().email().or(z.literal("")),
});
const schema = z.union([requiredEmailSchema, optionalEmailSchema]);
type NewParticipantFormData = z.infer<typeof schema>;
interface NewParticipantModalProps { interface NewParticipantModalProps {
votes: { optionId: string; type: VoteType }[]; votes: { optionId: string; type: VoteType }[];
@ -70,116 +82,87 @@ const VoteSummary = ({
); );
}; };
export const NewParticipantModal = (props: NewParticipantModalProps) => { export const NewParticipantForm = (props: NewParticipantModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const poll = usePoll();
const isEmailRequired = poll.requireParticipantEmail;
const { register, formState, setFocus, handleSubmit } = const { register, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>(); useForm<NewParticipantFormData>({
const { requiredString, validEmail } = useFormValidation(); resolver: zodResolver(schema),
const { poll } = usePoll(); defaultValues: {
requireEmail: isEmailRequired,
},
});
const addParticipant = useAddParticipantMutation(); const addParticipant = useAddParticipantMutation();
useMount(() => { useMount(() => {
setFocus("name"); setFocus("name");
}); });
return ( return (
<div className="max-w-full p-4"> <form
<div className="text-lg font-semibold text-gray-800"> onSubmit={handleSubmit(async (data) => {
{t("newParticipant")} const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
email: data.email,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
})}
className="space-y-4"
>
<fieldset>
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<TextInput
className="w-full"
data-1p-ignore="true"
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name")}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset>
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
{!isEmailRequired ? ` (${t("optional")})` : null}
</label>
<TextInput
className="w-full"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email")}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<fieldset>
<label className="mb-1 text-gray-500">{t("response")}</label>
<VoteSummary votes={props.votes} />
</fieldset>
<div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button>
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
{t("submit")}
</Button>
</div> </div>
<div className="mb-4">{t("newParticipantFormDescription")}</div> </form>
<form
onSubmit={handleSubmit(async (data) => {
const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
email: data.email,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
})}
className="space-y-4"
>
<fieldset>
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<TextInput
className="w-full"
data-1p-ignore="true"
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString(t("name")) })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset>
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")} ({t("optional")})
</label>
<TextInput
className="w-full"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", {
validate: (value) => {
if (!value) return true;
return validEmail(value);
},
})}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<fieldset>
<label className="mb-1 text-gray-500">{t("response")}</label>
<VoteSummary votes={props.votes} />
</fieldset>
<div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button>
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
{t("submit")}
</Button>
</div>
</form>
</div>
); );
}; };
export const useNewParticipantModal = () => {
const modalContext = useModalContext();
const showNewParticipantModal = (props: NewParticipantModalProps) => {
return modalContext.render({
showClose: true,
overlayClosable: true,
content: function Content({ close }) {
return (
<NewParticipantModal
{...props}
onSubmit={(data) => {
props.onSubmit?.(data);
close();
}}
onCancel={close}
/>
);
},
footer: null,
});
};
return showNewParticipantModal;
};

View file

@ -6,6 +6,7 @@ import Discussion from "@/components/discussion";
import { EventCard } from "@/components/event-card"; import { EventCard } from "@/components/event-card";
import DesktopPoll from "@/components/poll/desktop-poll"; import DesktopPoll from "@/components/poll/desktop-poll";
import MobilePoll from "@/components/poll/mobile-poll"; import MobilePoll from "@/components/poll/mobile-poll";
import { VotingForm } from "@/components/poll/voting-form";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { useTouchBeacon } from "./poll/use-touch-beacon"; import { useTouchBeacon } from "./poll/use-touch-beacon";
@ -34,7 +35,9 @@ export const Poll = () => {
<div className={cn("space-y-3 sm:space-y-4")}> <div className={cn("space-y-3 sm:space-y-4")}>
<EventCard /> <EventCard />
<Card fullWidthOnMobile={false}> <Card fullWidthOnMobile={false}>
<PollComponent /> <VotingForm>
<PollComponent />
</VotingForm>
</Card> </Card>
{poll.disableComments ? null : ( {poll.disableComments ? null : (
<> <>

View file

@ -15,7 +15,7 @@ import { RemoveScroll } from "react-remove-scroll";
import { useMeasure, useScroll } from "react-use"; import { useMeasure, useScroll } from "react-use";
import { TimesShownIn } from "@/components/clock"; 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 { usePermissions } from "@/contexts/permissions";
import { import {
@ -266,8 +266,9 @@ const DesktopPoll: React.FunctionComponent = () => {
key={i} key={i}
participant={participant} participant={participant}
editMode={ editMode={
votingForm.watch("mode") === "edit" &&
votingForm.watch("participantId") === votingForm.watch("participantId") ===
participant.id participant.id
} }
onChangeEditMode={(isEditing) => { onChangeEditMode={(isEditing) => {
if (isEditing) { if (isEditing) {
@ -322,12 +323,4 @@ const DesktopPoll: React.FunctionComponent = () => {
); );
}; };
const WrappedDesktopPoll = () => { export default DesktopPoll;
return (
<VotingForm>
<DesktopPoll />
</VotingForm>
);
};
export default WrappedDesktopPoll;

View file

@ -65,7 +65,6 @@ const ManagePoll: React.FunctionComponent<{
<Link href={`/poll/${poll.id}/edit-settings`}> <Link href={`/poll/${poll.id}/edit-settings`}>
<DropdownMenuItemIconLabel icon={Settings2Icon}> <DropdownMenuItemIconLabel icon={Settings2Icon}>
<Trans i18nKey="editSettings" defaults="Edit settings" /> <Trans i18nKey="editSettings" defaults="Edit settings" />
<ProBadge />
</DropdownMenuItemIconLabel> </DropdownMenuItemIconLabel>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -4,25 +4,21 @@ import { Button } from "@rallly/ui/button";
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useUpdateEffect } from "react-use";
import smoothscroll from "smoothscroll-polyfill"; import smoothscroll from "smoothscroll-polyfill";
import { TimesShownIn } from "@/components/clock"; import { TimesShownIn } from "@/components/clock";
import { ParticipantDropdown } from "@/components/participant-dropdown"; import { ParticipantDropdown } from "@/components/participant-dropdown";
import { useVotingForm } from "@/components/poll/voting-form";
import { useOptions, usePoll } from "@/components/poll-context"; import { useOptions, usePoll } from "@/components/poll-context";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
import { styleMenuItem } from "../menu-styles"; import { styleMenuItem } from "../menu-styles";
import { useNewParticipantModal } from "../new-participant-modal";
import { import {
useParticipants, useParticipants,
useVisibleParticipants, useVisibleParticipants,
} from "../participants-provider"; } from "../participants-provider";
import { useUser } from "../user-provider"; import { useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options"; import GroupedOptions from "./mobile-poll/grouped-options";
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
import { ParticipantForm } from "./types";
import UserAvatar, { YouAvatar } from "./user-avatar"; import UserAvatar, { YouAvatar } from "./user-avatar";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -32,28 +28,17 @@ if (typeof window !== "undefined") {
const MobilePoll: React.FunctionComponent = () => { const MobilePoll: React.FunctionComponent = () => {
const pollContext = usePoll(); const pollContext = usePoll();
const { poll, admin, getParticipantById, optionIds, getVote } = pollContext; const { poll, getParticipantById } = pollContext;
const { options } = useOptions(); const { options } = useOptions();
const { participants } = useParticipants(); const { participants } = useParticipants();
const session = useUser(); const session = useUser();
const form = useForm<ParticipantForm>({ const votingForm = useVotingForm();
defaultValues: { const { formState } = votingForm;
votes: [],
},
});
const { reset, handleSubmit, formState } = form; const selectedParticipantId = votingForm.watch("participantId");
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
string | undefined
>(() => {
if (!admin) {
const participant = participants.find((p) => session.ownsObject(p));
return participant?.id;
}
});
const visibleParticipants = useVisibleParticipants(); const visibleParticipants = useVisibleParticipants();
const selectedParticipant = selectedParticipantId const selectedParticipant = selectedParticipantId
@ -62,199 +47,158 @@ const MobilePoll: React.FunctionComponent = () => {
const { canEditParticipant, canAddNewParticipant } = usePermissions(); 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<HTMLFormElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const updateParticipant = useUpdateParticipantMutation(); const isEditing = votingForm.watch("mode") !== "view";
const showNewParticipantModal = useNewParticipantModal();
return ( return (
<FormProvider {...form}> <>
<form <div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
ref={formRef} <div className="flex space-x-2">
onSubmit={handleSubmit(async ({ votes }) => { {selectedParticipantId || !isEditing ? (
if (selectedParticipant) { <Listbox
await updateParticipant.mutateAsync({ value={selectedParticipantId}
pollId: poll.id, onChange={(participantId) => {
participantId: selectedParticipant.id, votingForm.setValue("participantId", participantId);
votes: normalizeVotes(optionIds, votes),
});
setIsEditing(false);
} else {
showNewParticipantModal({
votes: normalizeVotes(optionIds, votes),
onSubmit: async ({ id }) => {
setSelectedParticipantId(id);
setIsEditing(false);
},
});
}
})}
>
<div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
<div className="flex space-x-2">
{selectedParticipantId || !isEditing ? (
<Listbox
value={selectedParticipantId}
onChange={(participantId) => {
setSelectedParticipantId(participantId);
}}
disabled={isEditing}
>
<div className="menu min-w-0 grow">
<Listbox.Button
as={Button}
className="w-full shadow-none"
data-testid="participant-selector"
>
<div className="min-w-0 grow text-left">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
<UserAvatar
name={selectedParticipant.name}
showName={true}
isYou={session.ownsObject(selectedParticipant)}
/>
</div>
) : (
t("participantCount", { count: participants.length })
)}
</div>
<ChevronDownIcon className="h-5 shrink-0" />
</Listbox.Button>
<Listbox.Options
as={m.div}
transition={{
duration: 0.1,
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}>
{t("participantCount", { count: participants.length })}
</Listbox.Option>
{visibleParticipants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvatar
name={participant.name}
showName={true}
isYou={session.ownsObject(participant)}
/>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
) : (
<div className="flex grow items-center px-1">
<YouAvatar />
</div>
)}
{isEditing ? (
<Button
onClick={() => {
setIsEditing(false);
reset();
}}
>
{t("cancel")}
</Button>
) : selectedParticipant ? (
<ParticipantDropdown
align="end"
disabled={!canEditParticipant(selectedParticipant.id)}
participant={selectedParticipant}
onEdit={() => {
setIsEditing(true);
reset({
votes: optionIds.map((optionId) => ({
optionId,
type: getVote(selectedParticipant.id, optionId),
})),
});
}}
>
<Button icon={MoreHorizontalIcon} />
</ParticipantDropdown>
) : canAddNewParticipant ? (
<Button
icon={PlusIcon}
onClick={() => {
reset({
votes: [],
});
setIsEditing(true);
}}
/>
) : null}
</div>
</div>
{poll.options[0].duration !== 0 ? (
<div className="flex border-b bg-gray-50 p-3">
<TimesShownIn />
</div>
) : null}
<GroupedOptions
selectedParticipantId={selectedParticipantId}
options={options}
editable={isEditing}
group={(option) => {
if (option.type === "timeSlot") {
return `${option.dow} ${option.day} ${option.month}`;
}
return `${option.month} ${option.year}`;
}}
/>
<AnimatePresence>
{isEditing ? (
<m.div
variants={{
hidden: { opacity: 0, y: -20, height: 0 },
visible: { opacity: 1, y: 0, height: "auto" },
}} }}
initial="hidden" disabled={isEditing}
animate="visible" >
exit={{ <div className="menu min-w-0 grow">
opacity: 0, <Listbox.Button
y: -10, as={Button}
height: 0, className="w-full shadow-none"
transition: { duration: 0.2 }, data-testid="participant-selector"
>
<div className="min-w-0 grow text-left">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
<UserAvatar
name={selectedParticipant.name}
showName={true}
isYou={session.ownsObject(selectedParticipant)}
/>
</div>
) : (
t("participantCount", { count: participants.length })
)}
</div>
<ChevronDownIcon className="h-5 shrink-0" />
</Listbox.Button>
<Listbox.Options
as={m.div}
transition={{
duration: 0.1,
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}>
{t("participantCount", { count: participants.length })}
</Listbox.Option>
{visibleParticipants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvatar
name={participant.name}
showName={true}
isYou={session.ownsObject(participant)}
/>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
) : (
<div className="flex grow items-center px-1">
<YouAvatar />
</div>
)}
{isEditing ? (
<Button
onClick={() => {
if (votingForm.watch("mode") === "new") {
votingForm.cancel();
} else {
votingForm.setValue("mode", "view");
}
}} }}
> >
<div className="space-y-3 border-t bg-gray-50 p-3"> {t("cancel")}
<Button </Button>
className="w-full" ) : selectedParticipant ? (
type="submit" <ParticipantDropdown
variant="primary" align="end"
loading={formState.isSubmitting} disabled={!canEditParticipant(selectedParticipant.id)}
> participant={selectedParticipant}
{selectedParticipantId ? t("save") : t("continue")} onEdit={() => {
</Button> votingForm.setEditingParticipantId(selectedParticipant.id);
</div> }}
</m.div> >
<Button icon={MoreHorizontalIcon} />
</ParticipantDropdown>
) : canAddNewParticipant ? (
<Button
icon={PlusIcon}
onClick={() => {
votingForm.newParticipant();
}}
/>
) : null} ) : null}
</AnimatePresence> </div>
</form> </div>
</FormProvider> {poll.options[0].duration !== 0 ? (
<div className="flex border-b bg-gray-50 p-3">
<TimesShownIn />
</div>
) : null}
<GroupedOptions
selectedParticipantId={selectedParticipantId}
options={options}
editable={isEditing}
group={(option) => {
if (option.type === "timeSlot") {
return `${option.dow} ${option.day} ${option.month}`;
}
return `${option.month} ${option.year}`;
}}
/>
<AnimatePresence>
{isEditing ? (
<m.div
variants={{
hidden: { opacity: 0, y: -20, height: 0 },
visible: { opacity: 1, y: 0, height: "auto" },
}}
initial="hidden"
animate="visible"
exit={{
opacity: 0,
y: -10,
height: 0,
transition: { duration: 0.2 },
}}
>
<div className="space-y-3 border-t bg-gray-50 p-3">
<Button
form="voting-form"
className="w-full"
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
{selectedParticipantId ? t("save") : t("continue")}
</Button>
</div>
</m.div>
) : null}
</AnimatePresence>
</>
); );
}; };

View file

@ -1,11 +1,11 @@
import { VoteType } from "@rallly/database"; import { VoteType } from "@rallly/database";
import * as React from "react"; 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 { usePoll } from "@/components/poll-context";
import { ParsedDateTimeOpton } from "@/utils/date-time-utils"; import { ParsedDateTimeOpton } from "@/utils/date-time-utils";
import { ParticipantForm } from "../types";
import DateOption from "./date-option"; import DateOption from "./date-option";
import TimeSlotOption from "./time-slot-option"; import TimeSlotOption from "./time-slot-option";
@ -20,7 +20,7 @@ const PollOptions: React.FunctionComponent<PollOptions> = ({
editable, editable,
selectedParticipantId, selectedParticipantId,
}) => { }) => {
const { control } = useFormContext<ParticipantForm>(); const { control } = useVotingForm();
const { const {
getParticipantsWhoVotedForOption, getParticipantsWhoVotedForOption,
getParticipantById, getParticipantById,

View file

@ -1,13 +1,21 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@rallly/ui/dialog";
import React from "react"; import React from "react";
import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { z } from "zod"; 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 { useParticipants } from "@/components/participants-provider";
import { import {
normalizeVotes, normalizeVotes,
useUpdateParticipantMutation, useUpdateParticipantMutation,
} from "@/components/poll/mutations"; } from "@/components/poll/mutations";
import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role"; import { useRole } from "@/contexts/role";
@ -70,7 +78,6 @@ export const useVotingForm = () => {
export const VotingForm = ({ children }: React.PropsWithChildren) => { export const VotingForm = ({ children }: React.PropsWithChildren) => {
const { id: pollId, options } = usePoll(); const { id: pollId, options } = usePoll();
const showNewParticipantModal = useNewParticipantModal();
const updateParticipant = useUpdateParticipantMutation(); const updateParticipant = useUpdateParticipantMutation();
const { participants } = useParticipants(); const { participants } = useParticipants();
@ -80,6 +87,10 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
); );
const role = useRole(); const role = useRole();
const optionIds = options.map((option) => option.id);
const [isNewParticipantModalOpen, setIsNewParticipantModalOpen] =
React.useState(false);
const form = useForm<VotingFormValues>({ const form = useForm<VotingFormValues>({
defaultValues: { defaultValues: {
@ -87,6 +98,10 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
canAddNewParticipant && !userAlreadyVoted && role === "participant" canAddNewParticipant && !userAlreadyVoted && role === "participant"
? "new" ? "new"
: "view", : "view",
participantId:
role === "participant"
? participants.find((p) => canEditParticipant(p.id))?.id
: undefined,
votes: options.map((option) => ({ votes: options.map((option) => ({
optionId: option.id, optionId: option.id,
})), })),
@ -98,8 +113,6 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
<form <form
id="voting-form" id="voting-form"
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit(async (data) => {
const optionIds = options.map((option) => option.id);
if (data.participantId) { if (data.participantId) {
// update participant // update participant
@ -110,6 +123,7 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
}); });
form.reset({ form.reset({
mode: "view",
participantId: undefined, participantId: undefined,
votes: options.map((option) => ({ votes: options.map((option) => ({
optionId: option.id, optionId: option.id,
@ -117,21 +131,39 @@ export const VotingForm = ({ children }: React.PropsWithChildren) => {
}); });
} else { } else {
// new participant // new participant
showNewParticipantModal({ setIsNewParticipantModalOpen(true);
votes: normalizeVotes(optionIds, data.votes),
onSubmit: async () => {
form.reset({
mode: "view",
participantId: undefined,
votes: options.map((option) => ({
optionId: option.id,
})),
});
},
});
} }
})} })}
/> />
<Dialog
open={isNewParticipantModalOpen}
onOpenChange={setIsNewParticipantModalOpen}
>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="newParticipant" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="newParticipantFormDescription" />
</DialogDescription>
</DialogHeader>
<NewParticipantForm
votes={normalizeVotes(optionIds, form.watch("votes"))}
onSubmit={(newParticipant) => {
form.reset({
mode: "view",
participantId: newParticipant.id,
votes: options.map((option) => ({
optionId: option.id,
})),
});
setIsNewParticipantModalOpen(false);
}}
onCancel={() => setIsNewParticipantModalOpen(false)}
/>
</DialogContent>
</Dialog>
{children} {children}
</FormProvider> </FormProvider>
); );

View file

@ -1,13 +1,36 @@
import { Badge } from "@rallly/ui/badge"; 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"; import { usePlan } from "@/contexts/plan";
export const ProBadge = ({ className }: { className?: string }) => { export const ProBadge = ({ className }: { className?: string }) => {
const isPaid = usePlan() === "paid"; const isPaid = usePlan() === "paid";
const router = useRouter();
if (isPaid) { if (isPaid) {
return null; return null;
} }
return <Badge className={className}>Pro</Badge>; return (
<Tooltip>
<TooltipTrigger
className="inline-flex"
type="button"
onClick={() => {
router.push("/settings/billing");
}}
>
<Badge className={className}>
<Trans i18nKey="planPro" />
</Badge>
</TooltipTrigger>
<TooltipContent>
<Trans
i18nKey="pleaseUpgrade"
defaults="Please upgrade to Pro to use this feature"
/>
</TooltipContent>
</Tooltip>
);
}; };

View file

@ -8,7 +8,7 @@ const supportedLocales = Object.keys(languages);
// these paths are always public // these paths are always public
const publicPaths = ["/login", "/register", "/invite", "/auth"]; const publicPaths = ["/login", "/register", "/invite", "/auth"];
// these paths always require authentication // these paths always require authentication
const protectedPaths = ["/settings/billing", "/settings/profile"]; const protectedPaths = ["/settings/profile"];
const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => { const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => {
const session = await getSession(req, res); const session = await getSession(req, res);

View file

@ -10,7 +10,6 @@ import {
PollSettingsFormData, PollSettingsFormData,
} from "@/components/forms/poll-settings"; } from "@/components/forms/poll-settings";
import { getPollLayout } from "@/components/layouts/poll-layout"; import { getPollLayout } from "@/components/layouts/poll-layout";
import { PayWall } from "@/components/pay-wall";
import { useUpdatePollMutation } from "@/components/poll/mutations"; import { useUpdatePollMutation } from "@/components/poll/mutations";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
@ -35,39 +34,38 @@ const Page: NextPageWithLayout = () => {
hideParticipants: poll.hideParticipants, hideParticipants: poll.hideParticipants,
hideScores: poll.hideScores, hideScores: poll.hideScores,
disableComments: poll.disableComments, disableComments: poll.disableComments,
requireParticipantEmail: poll.requireParticipantEmail,
}, },
}); });
return ( return (
<PayWall> <Form {...form}>
<Form {...form}> <form
<form className="mx-auto max-w-3xl"
className="mx-auto max-w-3xl" onSubmit={form.handleSubmit(async (data) => {
onSubmit={form.handleSubmit(async (data) => { //submit
//submit await update.mutateAsync(
await update.mutateAsync( { urlId: poll.adminUrlId, ...data },
{ urlId: poll.adminUrlId, ...data }, {
{ onSuccess: redirectBackToPoll,
onSuccess: redirectBackToPoll, },
}, );
); })}
})} >
> <PollSettingsForm>
<PollSettingsForm> <CardFooter className="justify-between">
<CardFooter className="justify-between"> <Button asChild>
<Button asChild> <Link href={pollLink}>
<Link href={pollLink}> <Trans i18nKey="cancel" />
<Trans i18nKey="cancel" /> </Link>
</Link> </Button>
</Button> <Button type="submit" variant="primary">
<Button type="submit" variant="primary"> <Trans i18nKey="save" />
<Trans i18nKey="save" /> </Button>
</Button> </CardFooter>
</CardFooter> </PollSettingsForm>
</PollSettingsForm> </form>
</form> </Form>
</Form>
</PayWall>
); );
}; };

View file

@ -58,6 +58,7 @@ export const polls = router({
hideParticipants: z.boolean().optional(), hideParticipants: z.boolean().optional(),
hideScores: z.boolean().optional(), hideScores: z.boolean().optional(),
disableComments: z.boolean().optional(), disableComments: z.boolean().optional(),
requireParticipantEmail: z.boolean().optional(),
options: z options: z
.object({ .object({
startDate: z.string(), startDate: z.string(),
@ -116,6 +117,7 @@ export const polls = router({
hideParticipants: input.hideParticipants, hideParticipants: input.hideParticipants,
disableComments: input.disableComments, disableComments: input.disableComments,
hideScores: input.hideScores, hideScores: input.hideScores,
requireParticipantEmail: input.requireParticipantEmail,
}, },
}); });
@ -161,6 +163,7 @@ export const polls = router({
hideParticipants: z.boolean().optional(), hideParticipants: z.boolean().optional(),
disableComments: z.boolean().optional(), disableComments: z.boolean().optional(),
hideScores: z.boolean().optional(), hideScores: z.boolean().optional(),
requireParticipantEmail: z.boolean().optional(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@ -211,6 +214,7 @@ export const polls = router({
hideScores: input.hideScores, hideScores: input.hideScores,
hideParticipants: input.hideParticipants, hideParticipants: input.hideParticipants,
disableComments: input.disableComments, disableComments: input.disableComments,
requireParticipantEmail: input.requireParticipantEmail,
}, },
}); });
}), }),
@ -377,6 +381,7 @@ export const polls = router({
hideParticipants: true, hideParticipants: true,
disableComments: true, disableComments: true,
hideScores: true, hideScores: true,
requireParticipantEmail: true,
demo: true, demo: true,
options: { options: {
select: { select: {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "polls" ADD COLUMN "require_participant_email" BOOLEAN NOT NULL DEFAULT false;

View file

@ -89,34 +89,35 @@ enum ParticipantVisibility {
} }
model Poll { model Poll {
id String @id @unique @map("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
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")
options Option[] options Option[]
participants Participant[] participants Participant[]
watchers Watcher[] watchers Watcher[]
demo Boolean @default(false) demo Boolean @default(false)
comments Comment[] comments Comment[]
legacy Boolean @default(false) // @deprecated legacy Boolean @default(false) // @deprecated
closed Boolean @default(false) // we use this to indicate whether a poll is paused closed Boolean @default(false) // we use this to indicate whether a poll is paused
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") participantUrlId String @unique @map("participant_url_id")
adminUrlId String @unique @map("admin_url_id") adminUrlId String @unique @map("admin_url_id")
eventId String? @map("event_id") eventId String? @map("event_id")
event Event? event Event?
hideParticipants Boolean @default(false) @map("hide_participants") hideParticipants Boolean @default(false) @map("hide_participants")
hideScores Boolean @default(false) @map("hide_scores") hideScores Boolean @default(false) @map("hide_scores")
disableComments Boolean @default(false) @map("disable_comments") disableComments Boolean @default(false) @map("disable_comments")
requireParticipantEmail Boolean @default(false) @map("require_participant_email")
@@index([userId], type: Hash) @@index([userId], type: Hash)
@@map("polls") @@map("polls")