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

View file

@ -39,6 +39,9 @@ export const CreatePoll: React.FunctionComponent = () => {
const form = useForm<NewEventData>({
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 = () => {
<PollSettingsForm />
<hr />
<Button
loading={form.formState.isSubmitting}
loading={
form.formState.isSubmitting || form.formState.isSubmitSuccessful
}
size="lg"
type="submit"
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 {
Card,
@ -7,10 +13,9 @@ import {
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import { FormField, FormItem } from "@rallly/ui/form";
import { Label } from "@rallly/ui/label";
import { FormField } from "@rallly/ui/form";
import { Switch } from "@rallly/ui/switch";
import Link from "next/link";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import React from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
@ -19,26 +24,57 @@ import { ProBadge } from "@/components/pro-badge";
import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = {
requireParticipantEmail: boolean;
hideParticipants: boolean;
hideScores: boolean;
disableComments: boolean;
};
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) => {
return <p className="text-muted-foreground text-sm">{children}</p>;
};
const SettingTitle = Label;
const Setting = ({ children }: React.PropsWithChildren) => {
const SettingTitle = ({
children,
pro,
hint,
}: React.PropsWithChildren<{
pro?: boolean;
htmlFor?: string;
hint?: React.ReactNode;
}>) => {
return (
<FormItem className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-x-4">{children}</div>
</FormItem>
<div className="flex min-w-0 items-center gap-x-2.5">
<div className="text-sm font-medium">{children}</div>
{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 disabled = plan === "free";
const isFree = plan === "free";
return (
<Card>
@ -58,7 +94,6 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
<CardTitle>
<Trans i18nKey="settings" />
</CardTitle>
<ProBadge />
</div>
<CardDescription>
<Trans
@ -67,43 +102,71 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
/>
</CardDescription>
</div>
{disabled ? (
<div>
<Link className="text-link text-sm" href="/settings/billing">
<Trans i18nKey="planUpgrade" />
</Link>
</div>
) : null}
</div>
</CardHeader>
<CardContent>
<div
className={cn(
"grid gap-2.5",
disabled ? "pointer-events-none opacity-50" : "",
<div className={cn("grid gap-2.5")}>
<FormField
control={form.control}
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
control={form.control}
name="hideParticipants"
render={({ field }) => (
<Setting>
<EyeIcon className="h-6 w-6" />
<Setting disabled={isFree}>
<EyeIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="hideParticipants">
Hide participant list
</Trans>
</SettingTitle>
<SettingDescription>
<SettingTitle pro>
<Trans
i18nKey="hideParticipantsDescription"
defaults="Keep participant details private"
i18nKey="hideParticipantsLabel"
defaults="Hide participants from each other"
/>
</SettingDescription>
</SettingTitle>
</SettingContent>
<Switch
disabled={disabled}
disabled={isFree}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
@ -116,48 +179,19 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
control={form.control}
name="hideScores"
render={({ field }) => (
<Setting>
<VoteIcon className="h-6 w-6" />
<Setting disabled={isFree}>
<VoteIcon className="h-5 w-5 shrink-0 translate-y-0.5" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="hideScores">Hide scores</Trans>
</SettingTitle>
<SettingDescription>
<SettingTitle htmlFor={field.name} pro>
<Trans
i18nKey="hideScoresDescription"
defaults="Reduce bias by hiding the current vote counts from participants"
i18nKey="hideScoresLabel"
defaults="Hide scores until after a participant has voted"
/>
</SettingDescription>
</SettingTitle>
</SettingContent>
<Switch
disabled={disabled}
checked={field.value}
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}
id={field.name}
disabled={isFree}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);

View file

@ -1,21 +1,33 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { VoteType } from "@rallly/database";
import { Button } from "@rallly/ui/button";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
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 VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import { TextInput } from "./text-input";
interface NewParticipantFormData {
name: string;
email?: string;
}
const requiredEmailSchema = z.object({
requireEmail: z.literal(true),
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 {
votes: { optionId: string; type: VoteType }[];
@ -70,23 +82,25 @@ const VoteSummary = ({
);
};
export const NewParticipantModal = (props: NewParticipantModalProps) => {
export const NewParticipantForm = (props: NewParticipantModalProps) => {
const { t } = useTranslation();
const poll = usePoll();
const isEmailRequired = poll.requireParticipantEmail;
const { register, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>();
const { requiredString, validEmail } = useFormValidation();
const { poll } = usePoll();
useForm<NewParticipantFormData>({
resolver: zodResolver(schema),
defaultValues: {
requireEmail: isEmailRequired,
},
});
const addParticipant = useAddParticipantMutation();
useMount(() => {
setFocus("name");
});
return (
<div className="max-w-full p-4">
<div className="text-lg font-semibold text-gray-800">
{t("newParticipant")}
</div>
<div className="mb-4">{t("newParticipantFormDescription")}</div>
<form
onSubmit={handleSubmit(async (data) => {
const newParticipant = await addParticipant.mutateAsync({
@ -109,7 +123,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString(t("name")) })}
{...register("name")}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
@ -119,19 +133,15 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</fieldset>
<fieldset>
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")} ({t("optional")})
{t("email")}
{!isEmailRequired ? ` (${t("optional")})` : null}
</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);
},
})}
{...register("email")}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
@ -154,32 +164,5 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</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 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 = () => {
<div className={cn("space-y-3 sm:space-y-4")}>
<EventCard />
<Card fullWidthOnMobile={false}>
<VotingForm>
<PollComponent />
</VotingForm>
</Card>
{poll.disableComments ? null : (
<>

View file

@ -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,6 +266,7 @@ const DesktopPoll: React.FunctionComponent = () => {
key={i}
participant={participant}
editMode={
votingForm.watch("mode") === "edit" &&
votingForm.watch("participantId") ===
participant.id
}
@ -322,12 +323,4 @@ const DesktopPoll: React.FunctionComponent = () => {
);
};
const WrappedDesktopPoll = () => {
return (
<VotingForm>
<DesktopPoll />
</VotingForm>
);
};
export default WrappedDesktopPoll;
export default DesktopPoll;

View file

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

View file

@ -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<ParticipantForm>({
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,54 +47,19 @@ 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<HTMLFormElement>(null);
const { t } = useTranslation();
const updateParticipant = useUpdateParticipantMutation();
const showNewParticipantModal = useNewParticipantModal();
const isEditing = votingForm.watch("mode") !== "view";
return (
<FormProvider {...form}>
<form
ref={formRef}
onSubmit={handleSubmit(async ({ votes }) => {
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);
},
});
}
})}
>
<>
<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);
votingForm.setValue("participantId", participantId);
}}
disabled={isEditing}
>
@ -172,8 +122,11 @@ const MobilePoll: React.FunctionComponent = () => {
{isEditing ? (
<Button
onClick={() => {
setIsEditing(false);
reset();
if (votingForm.watch("mode") === "new") {
votingForm.cancel();
} else {
votingForm.setValue("mode", "view");
}
}}
>
{t("cancel")}
@ -184,13 +137,7 @@ const MobilePoll: React.FunctionComponent = () => {
disabled={!canEditParticipant(selectedParticipant.id)}
participant={selectedParticipant}
onEdit={() => {
setIsEditing(true);
reset({
votes: optionIds.map((optionId) => ({
optionId,
type: getVote(selectedParticipant.id, optionId),
})),
});
votingForm.setEditingParticipantId(selectedParticipant.id);
}}
>
<Button icon={MoreHorizontalIcon} />
@ -199,10 +146,7 @@ const MobilePoll: React.FunctionComponent = () => {
<Button
icon={PlusIcon}
onClick={() => {
reset({
votes: [],
});
setIsEditing(true);
votingForm.newParticipant();
}}
/>
) : null}
@ -242,6 +186,7 @@ const MobilePoll: React.FunctionComponent = () => {
>
<div className="space-y-3 border-t bg-gray-50 p-3">
<Button
form="voting-form"
className="w-full"
type="submit"
variant="primary"
@ -253,8 +198,7 @@ const MobilePoll: React.FunctionComponent = () => {
</m.div>
) : null}
</AnimatePresence>
</form>
</FormProvider>
</>
);
};

View file

@ -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<PollOptions> = ({
editable,
selectedParticipantId,
}) => {
const { control } = useFormContext<ParticipantForm>();
const { control } = useVotingForm();
const {
getParticipantsWhoVotedForOption,
getParticipantById,

View file

@ -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<VotingFormValues>({
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) => {
<form
id="voting-form"
onSubmit={form.handleSubmit(async (data) => {
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 () => {
setIsNewParticipantModalOpen(true);
}
})}
/>
<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: undefined,
participantId: newParticipant.id,
votes: options.map((option) => ({
optionId: option.id,
})),
});
},
});
}
})}
setIsNewParticipantModalOpen(false);
}}
onCancel={() => setIsNewParticipantModalOpen(false)}
/>
</DialogContent>
</Dialog>
{children}
</FormProvider>
);

View file

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

View file

@ -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,11 +34,11 @@ const Page: NextPageWithLayout = () => {
hideParticipants: poll.hideParticipants,
hideScores: poll.hideScores,
disableComments: poll.disableComments,
requireParticipantEmail: poll.requireParticipantEmail,
},
});
return (
<PayWall>
<Form {...form}>
<form
className="mx-auto max-w-3xl"
@ -67,7 +66,6 @@ const Page: NextPageWithLayout = () => {
</PollSettingsForm>
</form>
</Form>
</PayWall>
);
};

View file

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

View file

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

View file

@ -117,6 +117,7 @@ model Poll {
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")