Use dialogs to finalize and duplicate polls (#1099)

This commit is contained in:
Luke Vella 2024-05-16 16:14:48 +08:00 committed by GitHub
parent dee3e1b7d0
commit 2185ec5b83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 442 additions and 397 deletions

View file

@ -0,0 +1,5 @@
import { notFound } from "next/navigation";
export default function CatchAllPage() {
notFound();
}

View file

@ -1,33 +0,0 @@
import { Button } from "@rallly/ui/button";
import { XCircleIcon } from "lucide-react";
import Link from "next/link";
import { getTranslation } from "@/app/i18n";
import {
PageDialog,
PageDialogDescription,
PageDialogFooter,
PageDialogHeader,
PageDialogTitle,
} from "@/components/page-dialog";
export default async function NotFound() {
// TODO (Luke Vella) [2023-10-31]: No way to get locale from not-found
// See: https://github.com/vercel/next.js/discussions/43179
const { t } = await getTranslation("en");
return (
<PageDialog icon={XCircleIcon}>
<PageDialogHeader>
<PageDialogTitle>{t("authErrorTitle")}</PageDialogTitle>
<PageDialogDescription>
{t("authErrorDescription")}
</PageDialogDescription>
</PageDialogHeader>
<PageDialogFooter>
<Button asChild variant="primary">
<Link href="/login">{t("authErrorCta")}</Link>
</Button>
</PageDialogFooter>
</PageDialog>
);
}

View file

@ -0,0 +1,83 @@
"use client";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogProps,
DialogTitle,
} from "@rallly/ui/dialog";
import { useRouter } from "next/navigation";
import { DuplicateForm } from "@/app/[locale]/poll/[urlId]/duplicate-form";
import { PayWallDialogContent } from "@/app/[locale]/poll/[urlId]/pay-wall-dialog-content";
import { trpc } from "@/app/providers";
import { Trans } from "@/components/trans";
import { usePostHog } from "@/utils/posthog";
const formName = "duplicate-form";
export function DuplicateDialog({
pollId,
pollTitle,
...props
}: DialogProps & { pollId: string; pollTitle: string }) {
const queryClient = trpc.useUtils();
const duplicate = trpc.polls.duplicate.useMutation();
const posthog = usePostHog();
const router = useRouter();
return (
<Dialog {...props}>
<PayWallDialogContent>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="duplicate" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="duplicateDescription" />
</DialogDescription>
</DialogHeader>
<DuplicateForm
name={formName}
defaultValues={{
title: pollTitle,
}}
onSubmit={(data) => {
duplicate.mutate(
{ pollId, newTitle: data.title },
{
onSuccess: async (res) => {
posthog?.capture("duplicate poll", {
pollId,
newPollId: res.id,
});
queryClient.invalidate();
router.push(`/poll/${res.id}`);
},
},
);
}}
/>
<DialogFooter>
<DialogClose asChild>
<Button>
<Trans i18nKey="cancel" />
</Button>
</DialogClose>
<Button
type="submit"
loading={duplicate.isLoading}
variant="primary"
form={formName}
>
<Trans i18nKey="duplicate" />
</Button>
</DialogFooter>
</DialogContent>
</PayWallDialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Trans } from "@/components/trans";
const formSchema = z.object({
title: z.string().trim().min(1),
});
type DuplicateFormData = z.infer<typeof formSchema>;
export function DuplicateForm({
name,
onSubmit,
defaultValues,
}: {
name?: string;
onSubmit?: (data: DuplicateFormData) => void;
defaultValues?: DuplicateFormData;
}) {
const form = useForm<DuplicateFormData>({
resolver: zodResolver(formSchema),
defaultValues,
});
return (
<Form {...form}>
<form
id={name}
onSubmit={form.handleSubmit((data) => {
onSubmit?.(data);
})}
>
<FormField
control={form.control}
name="title"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey="duplicateTitleLabel" defaults="Title" />
</FormLabel>
<Input {...field} className="w-full" />
<FormDescription>
<Trans
i18nKey="duplicateTitleDescription"
defaults="Hint: Give your new poll a unique title"
/>
</FormDescription>
</FormItem>
);
}}
/>
</form>
</Form>
);
}

View file

@ -1,125 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import {
Form,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PayWall } from "@/components/pay-wall";
import { usePoll } from "@/components/poll-context";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
const formSchema = z.object({
title: z.string().trim().min(1),
});
const Page: NextPageWithLayout = () => {
const { poll } = usePoll();
const duplicate = trpc.polls.duplicate.useMutation();
const router = useRouter();
const posthog = usePostHog();
const pollLink = `/poll/${poll.id}`;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: poll.title,
},
});
return (
<PayWall>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => {
//submit
duplicate.mutate(
{ pollId: poll.id, newTitle: data.title },
{
onSuccess: async (res) => {
posthog?.capture("duplicate poll", {
pollId: poll.id,
newPollId: res.id,
});
router.push(`/poll/${res.id}`);
},
},
);
})}
>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="duplicate" defaults="Duplicate" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="duplicateDescription"
defaults="Create a new poll based on this one"
/>
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="title"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey="duplicateTitleLabel" defaults="Title" />
</FormLabel>
<Input className="w-full" {...field} />
<FormDescription>
<Trans
i18nKey="duplicateTitleDescription"
defaults="Hint: Give your new poll a unique title"
/>
</FormDescription>
</FormItem>
);
}}
/>
</CardContent>
<CardFooter className="justify-between">
<Button asChild>
<Link href={pollLink}>
<Trans i18nKey="cancel" />
</Link>
</Button>
<Button
type="submit"
loading={duplicate.isLoading}
variant="primary"
>
<Trans i18nKey="duplicate" defaults="Duplicate" />
</Button>
</CardFooter>
</Card>
</form>
</Form>
</PayWall>
);
};
export default Page;

View file

@ -1,96 +0,0 @@
"use client";
import { Button } from "@rallly/ui/button";
import {
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import { useRouter } from "next/navigation";
import { Card } from "@/components/card";
import { PayWall } from "@/components/pay-wall";
import { FinalizePollForm } from "@/components/poll/manage-poll/finalize-poll-dialog";
import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan";
import { usePoll } from "@/contexts/poll";
import { NextPageWithLayout } from "@/types";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
const FinalizationForm = () => {
const plan = usePlan();
const poll = usePoll();
const posthog = usePostHog();
const router = useRouter();
const redirectBackToPoll = () => {
router.replace(`/poll/${poll.id}`);
};
const queryClient = trpc.useUtils();
const bookDate = trpc.polls.book.useMutation({
onSuccess: () => {
queryClient.polls.invalidate();
redirectBackToPoll();
},
});
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="finalize" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="finalizeDescription"
defaults="Select a final date for your event."
/>
</CardDescription>
</CardHeader>
<CardContent>
<FinalizePollForm
name="finalize"
onSubmit={(data) => {
if (plan === "paid") {
bookDate.mutateAsync({
pollId: poll.id,
optionId: data.selectedOptionId,
notify: data.notify,
});
posthog?.capture("finalize poll", {
pollId: poll.id,
});
}
}}
/>
</CardContent>
<CardFooter className="flex justify-between">
<Button onClick={redirectBackToPoll}>
<Trans i18nKey="cancel" />
</Button>
<Button
type="submit"
loading={bookDate.isLoading}
form="finalize"
variant="primary"
>
<Trans i18nKey="finalize" />
</Button>
</CardFooter>
</Card>
);
};
const Page: NextPageWithLayout = () => {
return (
<PayWall>
<FinalizationForm />
</PayWall>
);
};
export default Page;

View file

@ -0,0 +1,67 @@
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { DialogClose, DialogContent } from "@rallly/ui/dialog";
import { m } from "framer-motion";
import Link from "next/link";
import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan";
export function PayWallDialogContent({
children,
}: {
children?: React.ReactNode;
}) {
const plan = usePlan();
if (plan === "free") {
return (
<DialogContent size="sm">
<div>
<m.div
transition={{
delay: 0.5,
duration: 0.4,
type: "spring",
bounce: 0.5,
}}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
aria-hidden="true"
>
<Badge variant="primary">
<Trans i18nKey="planPro" />
</Badge>
</m.div>
<div className="mt-4 space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-center text-xl font-bold">
<Trans defaults="Pro Feature" i18nKey="proFeature" />
</h2>
<p className="text-muted-foreground mx-auto max-w-xs text-center text-sm leading-relaxed">
<Trans
i18nKey="upgradeOverlaySubtitle2"
defaults="Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)"
/>
</p>
</div>
<div className="grid gap-2.5">
<Button variant="primary" asChild>
<Link href="/settings/billing">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Link>
</Button>
<DialogClose asChild>
<Button>
<Trans i18nKey="notToday" defaults="Not Today" />
</Button>
</DialogClose>
</div>
</div>
</div>
</DialogContent>
);
}
return <>{children}</>;
}

View file

@ -222,7 +222,7 @@ function DiscussionInner() {
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
onSelect={() => { onClick={() => {
deleteComment.mutate({ deleteComment.mutate({
commentId: comment.id, commentId: comment.id,
}); });

View file

@ -1,4 +1,5 @@
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { useDialog } from "@rallly/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -25,14 +26,99 @@ import {
import Link from "next/link"; import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
import { trpc } from "@/app/providers";
import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog";
import { ProFeatureBadge } from "@/components/pro-feature-badge"; import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog"; import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
import { useCsvExporter } from "./manage-poll/use-csv-exporter"; import { useCsvExporter } from "./manage-poll/use-csv-exporter";
function PauseResumeToggle() {
const poll = usePoll();
const queryClient = trpc.useUtils();
const resume = trpc.polls.resume.useMutation({
onSuccess: (_data, vars) => {
queryClient.polls.get.setData({ urlId: vars.pollId }, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
status: "live",
};
});
queryClient.polls.invalidate();
},
});
const pause = trpc.polls.pause.useMutation({
onSuccess: (_data, vars) => {
queryClient.polls.get.setData({ urlId: vars.pollId }, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
status: "paused",
};
});
queryClient.polls.invalidate();
},
});
if (poll.status === "paused") {
return (
<DropdownMenuItem
onClick={() => {
resume.mutate(
{ pollId: poll.id },
{
onSuccess: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
status: "live",
};
});
},
},
);
}}
>
<Icon>
<PlayIcon />
</Icon>
<Trans i18nKey="resumePoll" />
</DropdownMenuItem>
);
} else {
return (
<DropdownMenuItem
onClick={() => {
pause.mutate(
{ pollId: poll.id },
{
onSuccess: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
status: "paused",
};
});
},
},
);
}}
>
<Icon>
<PauseIcon />
</Icon>
<Trans i18nKey="pausePoll" />
</DropdownMenuItem>
);
}
}
const ManagePoll: React.FunctionComponent<{ const ManagePoll: React.FunctionComponent<{
disabled?: boolean; disabled?: boolean;
}> = ({ disabled }) => { }> = ({ disabled }) => {
@ -54,42 +140,10 @@ const ManagePoll: React.FunctionComponent<{
queryClient.polls.invalidate(); queryClient.polls.invalidate();
}, },
}); });
const pause = trpc.polls.pause.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: true,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const resume = trpc.polls.resume.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: false,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const [showDeletePollDialog, setShowDeletePollDialog] = React.useState(false); const [showDeletePollDialog, setShowDeletePollDialog] = React.useState(false);
const duplicateDialog = useDialog();
const finalizeDialog = useDialog();
const { exportToCsv } = useCsvExporter(); const { exportToCsv } = useCsvExporter();
return ( return (
@ -101,14 +155,16 @@ const ManagePoll: React.FunctionComponent<{
<SettingsIcon /> <SettingsIcon />
</Icon> </Icon>
<Trans i18nKey="manage" /> <Trans i18nKey="manage" />
<ChevronDownIcon className="size-4" /> <Icon>
<ChevronDownIcon />
</Icon>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<> <>
{poll.status === "finalized" ? ( {poll.status === "finalized" ? (
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onClick={() => {
reopen.mutate({ pollId: poll.id }); reopen.mutate({ pollId: poll.id });
}} }}
> >
@ -119,37 +175,19 @@ const ManagePoll: React.FunctionComponent<{
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<> <>
<DropdownMenuItem asChild disabled={!!poll.event}> <DropdownMenuItem
<Link href={`/poll/${poll.id}/finalize`}> disabled={!!poll.event}
<DropdownMenuItemIconLabel icon={CalendarCheck2Icon}> onClick={() => {
<Trans i18nKey="finishPoll" defaults="Finalize" /> finalizeDialog.trigger();
<ProFeatureBadge /> }}
</DropdownMenuItemIconLabel> >
</Link> <Icon>
<CalendarCheck2Icon />
</Icon>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProFeatureBadge />
</DropdownMenuItem> </DropdownMenuItem>
{poll.status === "live" ? ( <PauseResumeToggle />
<DropdownMenuItem
onSelect={() => {
pause.mutate({ pollId: poll.id });
}}
>
<Icon>
<PauseIcon />
</Icon>
<Trans i18nKey="pausePoll" defaults="Pause" />
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => {
resume.mutate({ pollId: poll.id });
}}
>
<Icon>
<PlayIcon />
</Icon>
<Trans i18nKey="resumePoll" defaults="Resume" />
</DropdownMenuItem>
)}
</> </>
)} )}
</> </>
@ -176,18 +214,21 @@ const ManagePoll: React.FunctionComponent<{
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={exportToCsv}> <DropdownMenuItem onClick={exportToCsv}>
<DropdownMenuItemIconLabel icon={DownloadIcon}> <DropdownMenuItemIconLabel icon={DownloadIcon}>
<Trans i18nKey="exportToCsv" /> <Trans i18nKey="exportToCsv" />
</DropdownMenuItemIconLabel> </DropdownMenuItemIconLabel>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem
<Link href={`/poll/${poll.id}/duplicate`}> onClick={() => {
<DropdownMenuItemIconLabel icon={CopyIcon}> duplicateDialog.trigger();
<Trans i18nKey="duplicate" defaults="Duplicate" /> }}
<ProFeatureBadge /> >
</DropdownMenuItemIconLabel> <DropdownMenuItemIconLabel icon={CopyIcon}>
</Link> <Trans i18nKey="duplicate" defaults="Duplicate" />
<ProFeatureBadge />
</DropdownMenuItemIconLabel>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@ -196,8 +237,8 @@ const ManagePoll: React.FunctionComponent<{
setShowDeletePollDialog(true); setShowDeletePollDialog(true);
}} }}
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4 opacity-75" />
<Trans i18nKey="deletePoll" /> <Trans i18nKey="delete" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -206,6 +247,12 @@ const ManagePoll: React.FunctionComponent<{
open={showDeletePollDialog} open={showDeletePollDialog}
onOpenChange={setShowDeletePollDialog} onOpenChange={setShowDeletePollDialog}
/> />
<DuplicateDialog
pollId={poll.id}
pollTitle={poll.title}
{...duplicateDialog.dialogProps}
/>
<FinalizePollDialog {...finalizeDialog.dialogProps} />
</> </>
); );
}; };

View file

@ -1,5 +1,15 @@
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogProps,
DialogTitle,
} from "@rallly/ui/dialog";
import { import {
Form, Form,
FormControl, FormControl,
@ -14,7 +24,9 @@ import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { DateIcon } from "@/components/date-icon"; import { PayWallDialogContent } from "@/app/[locale]/poll/[urlId]/pay-wall-dialog-content";
import { trpc } from "@/app/providers";
import { DateIconInner } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
import { ConnectedScoreSummary } from "@/components/poll/score-summary"; import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar"; import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
@ -58,7 +70,12 @@ const useScoreByOptionId = () => {
}, [responses, options]); }, [responses, options]);
}; };
const pageSize = 5; function DateIcon({ start }: { start: Date }) {
const poll = usePoll();
const { adjustTimeZone } = useDayjs();
const d = adjustTimeZone(start, !poll.timeZone);
return <DateIconInner dow={d.format("ddd")} day={d.format("D")} />;
}
export const FinalizePollForm = ({ export const FinalizePollForm = ({
name, name,
@ -68,7 +85,6 @@ export const FinalizePollForm = ({
onSubmit?: (data: FinalizeFormData) => void; onSubmit?: (data: FinalizeFormData) => void;
}) => { }) => {
const poll = usePoll(); const poll = usePoll();
const [max, setMax] = React.useState(pageSize);
const { adjustTimeZone } = useDayjs(); const { adjustTimeZone } = useDayjs();
const scoreByOptionId = useScoreByOptionId(); const scoreByOptionId = useScoreByOptionId();
@ -110,7 +126,9 @@ export const FinalizePollForm = ({
<form <form
id={name} id={name}
className="space-y-4" className="space-y-4"
onSubmit={form.handleSubmit((data) => onSubmit?.(data))} onSubmit={form.handleSubmit((data) => {
onSubmit?.(data);
})}
> >
<FormField <FormField
control={form.control} control={form.control}
@ -125,9 +143,9 @@ export const FinalizePollForm = ({
<RadioGroup <RadioGroup
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
className="grid gap-2" className="grid max-h-96 gap-2 overflow-y-auto rounded-lg border bg-gray-100 p-2"
> >
{options.slice(0, max).map((option) => { {options.map((option) => {
const start = adjustTimeZone( const start = adjustTimeZone(
option.startTime, option.startTime,
!poll.timeZone, !poll.timeZone,
@ -143,25 +161,17 @@ export const FinalizePollForm = ({
key={option.id} key={option.id}
htmlFor={option.id} htmlFor={option.id}
className={cn( className={cn(
"group flex select-none items-center gap-4 rounded-md border bg-white p-3 text-base", "group flex select-none items-start gap-4 rounded-lg border bg-white p-3 text-base",
field.value === option.id field.value === option.id ? "" : "",
? "bg-primary-50 border-primary"
: "hover:bg-gray-50",
)} )}
> >
<div className="hidden"> <RadioGroupItem id={option.id} value={option.id} />
<RadioGroupItem id={option.id} value={option.id} />
</div>
<div>
<DateIcon date={start} />
</div>
<div className="grow"> <div className="grow">
<div className="flex"> <div className="flex gap-x-4">
<DateIcon start={option.start} />
<div className="grow whitespace-nowrap"> <div className="grow whitespace-nowrap">
<div className="text-sm font-semibold"> <div className="text-sm font-medium">
{option.duration > 0 {start.format("LL")}
? start.format("LL")
: start.format("LL")}
</div> </div>
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{option.duration > 0 ? ( {option.duration > 0 ? (
@ -180,7 +190,7 @@ export const FinalizePollForm = ({
<ConnectedScoreSummary optionId={option.id} /> <ConnectedScoreSummary optionId={option.id} />
</div> </div>
</div> </div>
<div className="mt-2"> <div className="mt-4">
<VoteSummaryProgressBar <VoteSummaryProgressBar
{...scoreByOptionId[option.id]} {...scoreByOptionId[option.id]}
total={participants.length} total={participants.length}
@ -192,19 +202,6 @@ export const FinalizePollForm = ({
})} })}
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
{max < options.length ? (
<div className="absolute bottom-0 mt-2 w-full bg-gradient-to-t from-white via-white to-white/10 px-3 py-8">
<Button
variant="ghost"
className="w-full"
onClick={() => {
setMax((oldMax) => oldMax + pageSize);
}}
>
<Trans i18nKey="showMore" />
</Button>
</div>
) : null}
</FormItem> </FormItem>
); );
}} }}
@ -268,3 +265,58 @@ export const FinalizePollForm = ({
</Form> </Form>
); );
}; };
export function FinalizePollDialog(props: DialogProps) {
const poll = usePoll();
const queryClient = trpc.useUtils();
const scheduleEvent = trpc.polls.book.useMutation({
onSuccess: () => {
queryClient.invalidate();
},
});
return (
<Dialog {...props}>
<PayWallDialogContent>
<DialogContent size="2xl">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="finalize" />
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="finalizeDescription"
defaults="Select a final date for your event."
/>
</DialogDescription>
</DialogHeader>
<FinalizePollForm
name="finalize-form"
onSubmit={(data) => {
scheduleEvent.mutate({
pollId: poll.id,
optionId: data.selectedOptionId,
notify: data.notify,
});
props.onOpenChange?.(false);
}}
/>
<DialogFooter>
<DialogClose asChild>
<Button>
<Trans i18nKey="cancel" />
</Button>
</DialogClose>
<Button
loading={scheduleEvent.isLoading}
type="submit"
form="finalize-form"
variant="primary"
>
<Trans i18nKey="finalize" />
</Button>
</DialogFooter>
</DialogContent>
</PayWallDialogContent>
</Dialog>
);
}

View file

@ -168,7 +168,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
</IfGuest> </IfGuest>
<IfAuthenticated> <IfAuthenticated>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onClick={() => {
logout(); logout();
}} }}
className="flex items-center gap-x-2" className="flex items-center gap-x-2"

View file

@ -31,7 +31,7 @@ export const VoteSummaryProgressBar = (props: {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className="h-full bg-green-500 hover:opacity-75" className="h-full bg-green-500 opacity-75 hover:opacity-100"
style={{ style={{
width: (props.yes.length / props.total) * 100 + "%", width: (props.yes.length / props.total) * 100 + "%",
}} }}
@ -44,7 +44,7 @@ export const VoteSummaryProgressBar = (props: {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className="h-full bg-amber-400 hover:opacity-75" className="h-full bg-amber-400 opacity-75 hover:opacity-100"
style={{ style={{
width: (props.ifNeedBe.length / props.total) * 100 + "%", width: (props.ifNeedBe.length / props.total) * 100 + "%",
}} }}
@ -57,7 +57,7 @@ export const VoteSummaryProgressBar = (props: {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className="h-full bg-gray-300 hover:opacity-75" className="h-full bg-gray-300 opacity-75 hover:opacity-100"
style={{ style={{
width: (props.no.length / props.total) * 100 + "%", width: (props.no.length / props.total) * 100 + "%",
}} }}

View file

@ -1,22 +0,0 @@
import { FileSearchIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import React from "react";
import ErrorPage from "@/components/error-page";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/with-page-translations";
const Custom404: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<ErrorPage
icon={FileSearchIcon}
title={t("errors_notFoundTitle")}
description={t("errors_notFoundDescription")}
/>
);
};
export const getStaticProps = getStaticTranslations;
export default Custom404;

View file

@ -35,7 +35,7 @@ test.describe.serial(() => {
const manageButton = page.getByText("Manage"); const manageButton = page.getByText("Manage");
await manageButton.waitFor(); await manageButton.waitFor();
await manageButton.click(); await manageButton.click();
await page.click("text=Delete poll"); await page.click("text=Delete");
const deletePollDialog = page.getByRole("dialog"); const deletePollDialog = page.getByRole("dialog");

View file

@ -53,7 +53,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-8 shadow-huge z-50 grid w-full translate-y-0 gap-4 overflow-hidden rounded-md bg-gray-50 p-4 lg:p-5", "animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-8 shadow-huge z-50 grid w-full translate-y-0 gap-4 overflow-hidden rounded-md bg-gray-50 p-4",
{ {
"sm:max-w-sm": size === "sm", "sm:max-w-sm": size === "sm",
"sm:max-w-md": size === "md", "sm:max-w-md": size === "md",
@ -96,7 +96,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"bg-muted-background -mx-5 -mb-5 flex flex-col-reverse gap-2 border-t px-3 py-2.5 sm:flex-row sm:justify-end sm:rounded-b-md", "bg-muted-background -mx-4 -mb-4 flex flex-col-reverse gap-2 border-t px-3 py-2.5 sm:flex-row sm:justify-end sm:rounded-b-md",
className, className,
)} )}
{...props} {...props}

View file

@ -13,7 +13,7 @@ export type InputProps = Omit<
const inputVariants = cva( const inputVariants = cva(
cn( cn(
"focus:visible:border-primary-400 focus:visible:ring-primary-200 focus:visible:ring-2", "focus-visible:border-primary-400 focus-visible:ring-offset-1 focus-visible:outline-none focus-visible:ring-primary-200 focus-visible:ring-1",
"border-input placeholder:text-muted-foreground h-9 rounded border bg-gray-50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50", "border-input placeholder:text-muted-foreground h-9 rounded border bg-gray-50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
), ),
{ {

View file

@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea <textarea
className={cn( className={cn(
"border-input placeholder:text-muted-foreground flex min-h-[80px] rounded border bg-gray-50 px-2 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-50", "border-input placeholder:text-muted-foreground flex min-h-[80px] rounded border bg-gray-50 px-2 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:ring-offset-input-background focus-visible:ring-1 focus-visible:ring-offset-1", "focus-visible:ring-offset-input-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1",
"focus-visible:border-primary-400 focus-visible:ring-primary-100", "focus-visible:border-primary-400 focus-visible:ring-primary-100",
className, className,
)} )}