mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-21 04:46:22 +02:00
✨ Use dialogs to finalize and duplicate polls (#1099)
This commit is contained in:
parent
dee3e1b7d0
commit
2185ec5b83
17 changed files with 442 additions and 397 deletions
5
apps/web/src/app/[locale]/[...rest]/page.tsx
Normal file
5
apps/web/src/app/[locale]/[...rest]/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
83
apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx
Normal file
83
apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
67
apps/web/src/app/[locale]/poll/[urlId]/duplicate-form.tsx
Normal file
67
apps/web/src/app/[locale]/poll/[urlId]/duplicate-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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}</>;
|
||||
}
|
|
@ -222,7 +222,7 @@ function DiscussionInner() {
|
|||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
deleteComment.mutate({
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Button } from "@rallly/ui/button";
|
||||
import { useDialog } from "@rallly/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
@ -25,14 +26,99 @@ import {
|
|||
import Link from "next/link";
|
||||
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 { Trans } from "@/components/trans";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
|
||||
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<{
|
||||
disabled?: boolean;
|
||||
}> = ({ disabled }) => {
|
||||
|
@ -54,42 +140,10 @@ const ManagePoll: React.FunctionComponent<{
|
|||
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 duplicateDialog = useDialog();
|
||||
const finalizeDialog = useDialog();
|
||||
const { exportToCsv } = useCsvExporter();
|
||||
|
||||
return (
|
||||
|
@ -101,14 +155,16 @@ const ManagePoll: React.FunctionComponent<{
|
|||
<SettingsIcon />
|
||||
</Icon>
|
||||
<Trans i18nKey="manage" />
|
||||
<ChevronDownIcon className="size-4" />
|
||||
<Icon>
|
||||
<ChevronDownIcon />
|
||||
</Icon>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<>
|
||||
{poll.status === "finalized" ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
reopen.mutate({ pollId: poll.id });
|
||||
}}
|
||||
>
|
||||
|
@ -119,37 +175,19 @@ const ManagePoll: React.FunctionComponent<{
|
|||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem asChild disabled={!!poll.event}>
|
||||
<Link href={`/poll/${poll.id}/finalize`}>
|
||||
<DropdownMenuItemIconLabel icon={CalendarCheck2Icon}>
|
||||
<DropdownMenuItem
|
||||
disabled={!!poll.event}
|
||||
onClick={() => {
|
||||
finalizeDialog.trigger();
|
||||
}}
|
||||
>
|
||||
<Icon>
|
||||
<CalendarCheck2Icon />
|
||||
</Icon>
|
||||
<Trans i18nKey="finishPoll" defaults="Finalize" />
|
||||
<ProFeatureBadge />
|
||||
</DropdownMenuItemIconLabel>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{poll.status === "live" ? (
|
||||
<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>
|
||||
)}
|
||||
<PauseResumeToggle />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -176,18 +214,21 @@ const ManagePoll: React.FunctionComponent<{
|
|||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={exportToCsv}>
|
||||
<DropdownMenuItemIconLabel icon={DownloadIcon}>
|
||||
<Trans i18nKey="exportToCsv" />
|
||||
</DropdownMenuItemIconLabel>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/poll/${poll.id}/duplicate`}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
duplicateDialog.trigger();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItemIconLabel icon={CopyIcon}>
|
||||
<Trans i18nKey="duplicate" defaults="Duplicate" />
|
||||
<ProFeatureBadge />
|
||||
</DropdownMenuItemIconLabel>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
@ -196,8 +237,8 @@ const ManagePoll: React.FunctionComponent<{
|
|||
setShowDeletePollDialog(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<Trans i18nKey="deletePoll" />
|
||||
<TrashIcon className="size-4 opacity-75" />
|
||||
<Trans i18nKey="delete" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -206,6 +247,12 @@ const ManagePoll: React.FunctionComponent<{
|
|||
open={showDeletePollDialog}
|
||||
onOpenChange={setShowDeletePollDialog}
|
||||
/>
|
||||
<DuplicateDialog
|
||||
pollId={poll.id}
|
||||
pollTitle={poll.title}
|
||||
{...duplicateDialog.dialogProps}
|
||||
/>
|
||||
<FinalizePollDialog {...finalizeDialog.dialogProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogProps,
|
||||
DialogTitle,
|
||||
} from "@rallly/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
@ -14,7 +24,9 @@ import React from "react";
|
|||
import { useForm } from "react-hook-form";
|
||||
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 { ConnectedScoreSummary } from "@/components/poll/score-summary";
|
||||
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
|
||||
|
@ -58,7 +70,12 @@ const useScoreByOptionId = () => {
|
|||
}, [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 = ({
|
||||
name,
|
||||
|
@ -68,7 +85,6 @@ export const FinalizePollForm = ({
|
|||
onSubmit?: (data: FinalizeFormData) => void;
|
||||
}) => {
|
||||
const poll = usePoll();
|
||||
const [max, setMax] = React.useState(pageSize);
|
||||
|
||||
const { adjustTimeZone } = useDayjs();
|
||||
const scoreByOptionId = useScoreByOptionId();
|
||||
|
@ -110,7 +126,9 @@ export const FinalizePollForm = ({
|
|||
<form
|
||||
id={name}
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((data) => onSubmit?.(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSubmit?.(data);
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
@ -125,9 +143,9 @@ export const FinalizePollForm = ({
|
|||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
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(
|
||||
option.startTime,
|
||||
!poll.timeZone,
|
||||
|
@ -143,25 +161,17 @@ export const FinalizePollForm = ({
|
|||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
className={cn(
|
||||
"group flex select-none items-center gap-4 rounded-md border bg-white p-3 text-base",
|
||||
field.value === option.id
|
||||
? "bg-primary-50 border-primary"
|
||||
: "hover:bg-gray-50",
|
||||
"group flex select-none items-start gap-4 rounded-lg border bg-white p-3 text-base",
|
||||
field.value === option.id ? "" : "",
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
<RadioGroupItem id={option.id} value={option.id} />
|
||||
</div>
|
||||
<div>
|
||||
<DateIcon date={start} />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="flex">
|
||||
<div className="flex gap-x-4">
|
||||
<DateIcon start={option.start} />
|
||||
<div className="grow whitespace-nowrap">
|
||||
<div className="text-sm font-semibold">
|
||||
{option.duration > 0
|
||||
? start.format("LL")
|
||||
: start.format("LL")}
|
||||
<div className="text-sm font-medium">
|
||||
{start.format("LL")}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{option.duration > 0 ? (
|
||||
|
@ -180,7 +190,7 @@ export const FinalizePollForm = ({
|
|||
<ConnectedScoreSummary optionId={option.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="mt-4">
|
||||
<VoteSummaryProgressBar
|
||||
{...scoreByOptionId[option.id]}
|
||||
total={participants.length}
|
||||
|
@ -192,19 +202,6 @@ export const FinalizePollForm = ({
|
|||
})}
|
||||
</RadioGroup>
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
|
@ -268,3 +265,58 @@ export const FinalizePollForm = ({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
|
|||
</IfGuest>
|
||||
<IfAuthenticated>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
className="flex items-center gap-x-2"
|
||||
|
|
|
@ -31,7 +31,7 @@ export const VoteSummaryProgressBar = (props: {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full bg-green-500 hover:opacity-75"
|
||||
className="h-full bg-green-500 opacity-75 hover:opacity-100"
|
||||
style={{
|
||||
width: (props.yes.length / props.total) * 100 + "%",
|
||||
}}
|
||||
|
@ -44,7 +44,7 @@ export const VoteSummaryProgressBar = (props: {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full bg-amber-400 hover:opacity-75"
|
||||
className="h-full bg-amber-400 opacity-75 hover:opacity-100"
|
||||
style={{
|
||||
width: (props.ifNeedBe.length / props.total) * 100 + "%",
|
||||
}}
|
||||
|
@ -57,7 +57,7 @@ export const VoteSummaryProgressBar = (props: {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full bg-gray-300 hover:opacity-75"
|
||||
className="h-full bg-gray-300 opacity-75 hover:opacity-100"
|
||||
style={{
|
||||
width: (props.no.length / props.total) * 100 + "%",
|
||||
}}
|
||||
|
|
|
@ -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;
|
|
@ -35,7 +35,7 @@ test.describe.serial(() => {
|
|||
const manageButton = page.getByText("Manage");
|
||||
await manageButton.waitFor();
|
||||
await manageButton.click();
|
||||
await page.click("text=Delete poll");
|
||||
await page.click("text=Delete");
|
||||
|
||||
const deletePollDialog = page.getByRole("dialog");
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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-md": size === "md",
|
||||
|
@ -96,7 +96,7 @@ const DialogFooter = ({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -13,7 +13,7 @@ export type InputProps = Omit<
|
|||
|
||||
const inputVariants = cva(
|
||||
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",
|
||||
),
|
||||
{
|
||||
|
|
|
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
<textarea
|
||||
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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue