mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-14 17:36:49 +02:00
✨ Allow making email required (#864)
This commit is contained in:
parent
b9d4b31f38
commit
a9253bd972
16 changed files with 516 additions and 495 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,116 +82,87 @@ 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")}
|
||||
<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")}
|
||||
/>
|
||||
{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 className="mb-4">{t("newParticipantFormDescription")}</div>
|
||||
<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>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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}>
|
||||
<PollComponent />
|
||||
<VotingForm>
|
||||
<PollComponent />
|
||||
</VotingForm>
|
||||
</Card>
|
||||
{poll.disableComments ? null : (
|
||||
<>
|
||||
|
|
|
@ -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 (
|
||||
<VotingForm>
|
||||
<DesktopPoll />
|
||||
</VotingForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default WrappedDesktopPoll;
|
||||
export default DesktopPoll;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,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<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);
|
||||
}}
|
||||
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" },
|
||||
<>
|
||||
<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) => {
|
||||
votingForm.setValue("participantId", participantId);
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
height: 0,
|
||||
transition: { duration: 0.2 },
|
||||
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={() => {
|
||||
if (votingForm.watch("mode") === "new") {
|
||||
votingForm.cancel();
|
||||
} else {
|
||||
votingForm.setValue("mode", "view");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3 border-t bg-gray-50 p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
{selectedParticipantId ? t("save") : t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : selectedParticipant ? (
|
||||
<ParticipantDropdown
|
||||
align="end"
|
||||
disabled={!canEditParticipant(selectedParticipant.id)}
|
||||
participant={selectedParticipant}
|
||||
onEdit={() => {
|
||||
votingForm.setEditingParticipantId(selectedParticipant.id);
|
||||
}}
|
||||
>
|
||||
<Button icon={MoreHorizontalIcon} />
|
||||
</ParticipantDropdown>
|
||||
) : canAddNewParticipant ? (
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
votingForm.newParticipant();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</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"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 () => {
|
||||
form.reset({
|
||||
mode: "view",
|
||||
participantId: undefined,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
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: newParticipant.id,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
});
|
||||
setIsNewParticipantModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsNewParticipantModalOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{children}
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 (
|
||||
<PayWall>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
//submit
|
||||
await update.mutateAsync(
|
||||
{ urlId: poll.adminUrlId, ...data },
|
||||
{
|
||||
onSuccess: redirectBackToPoll,
|
||||
},
|
||||
);
|
||||
})}
|
||||
>
|
||||
<PollSettingsForm>
|
||||
<CardFooter className="justify-between">
|
||||
<Button asChild>
|
||||
<Link href={pollLink}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</PollSettingsForm>
|
||||
</form>
|
||||
</Form>
|
||||
</PayWall>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
//submit
|
||||
await update.mutateAsync(
|
||||
{ urlId: poll.adminUrlId, ...data },
|
||||
{
|
||||
onSuccess: redirectBackToPoll,
|
||||
},
|
||||
);
|
||||
})}
|
||||
>
|
||||
<PollSettingsForm>
|
||||
<CardFooter className="justify-between">
|
||||
<Button asChild>
|
||||
<Link href={pollLink}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</PollSettingsForm>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "polls" ADD COLUMN "require_participant_email" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -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")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue