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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
|
@ -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 : (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 {
|
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")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue