rallly/apps/web/src/components/new-participant-modal.tsx
2025-03-04 16:15:10 +00:00

188 lines
5.4 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import type { VoteType } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import * as Sentry from "@sentry/nextjs";
import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import z from "zod";
import { usePoll } from "@/contexts/poll";
import { useTranslation } from "@/i18n/client";
import { useAddParticipantMutation } from "./poll/mutations";
import VoteIcon from "./poll/vote-icon";
import { useUser } from "./user-provider";
const requiredEmailSchema = z.object({
requireEmail: z.literal(true),
name: z.string().min(1).max(100),
email: z.string().email(),
});
const optionalEmailSchema = z.object({
requireEmail: z.literal(false),
name: z.string().min(1).max(100),
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 }[];
onSubmit?: (data: { id: string }) => void;
onCancel?: () => void;
}
const VoteSummary = ({
votes,
className,
}: {
className?: string;
votes: { optionId: string; type: VoteType }[];
}) => {
const { t } = useTranslation();
const voteByType = votes.reduce<Record<VoteType, string[]>>(
(acc, vote) => {
acc[vote.type] = [...acc[vote.type], vote.optionId];
return acc;
},
{ yes: [], ifNeedBe: [], no: [] },
);
const voteTypes = Object.keys(voteByType) as VoteType[];
return (
<div
className={cn("flex flex-wrap gap-1.5 rounded border p-1.5", className)}
>
{voteTypes.map((voteType) => {
const votes = voteByType[voteType];
const count = votes.length;
if (count === 0) {
return null;
}
return (
<div
key={voteType}
className="flex h-8 select-none gap-2.5 rounded-lg border bg-gray-50 p-1 text-sm"
>
<div className="flex items-center gap-2">
<VoteIcon type={voteType} />
<div className="text-muted-foreground">{t(voteType)}</div>
</div>
<Badge>{voteByType[voteType].length}</Badge>
</div>
);
})}
</div>
);
};
export const NewParticipantForm = (props: NewParticipantModalProps) => {
const { t } = useTranslation();
const poll = usePoll();
const isEmailRequired = poll.requireParticipantEmail;
const { user } = useUser();
const isLoggedIn = !user.isGuest;
const { register, setError, formState, handleSubmit } =
useForm<NewParticipantFormData>({
resolver: zodResolver(schema),
defaultValues: {
requireEmail: isEmailRequired,
...(isLoggedIn
? { name: user.name, email: user.email ?? "" }
: {
name: "",
email: "",
}),
},
});
const addParticipant = useAddParticipantMutation();
return (
<form
onSubmit={handleSubmit(async (data) => {
try {
const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
email: data.email,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
} catch (error) {
if (error instanceof TRPCClientError) {
setError("root", {
message: error.message,
});
}
Sentry.captureException(error);
}
})}
className="space-y-4"
>
<fieldset>
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<Input
className="w-full"
data-1p-ignore="true"
autoFocus={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>
<Input
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>
{formState.errors.root?.message ? (
<FormMessage>{formState.errors.root.message}</FormMessage>
) : null}
<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>
);
};