Allow duplicating a poll (#804)

This commit is contained in:
Luke Vella 2023-08-09 09:48:02 +01:00 committed by GitHub
parent f68579eef2
commit 996743caaf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 699 additions and 224 deletions

View file

@ -50,7 +50,7 @@ const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
} else {
return absoluteUrl(`/${router.locale}${path}`);
}
}, [router.defaultLocale, router.locale, router.pathname]);
}, [router.defaultLocale, router.locale, router.asPath]);
const getLayout = Component.getLayout ?? ((page) => page);

View file

@ -166,9 +166,6 @@
"subscriptionDescription": "Get access to more features by upgrading to a paid plan.",
"freeForever": "free forever",
"annualBillingDescription": "per month, billed annually",
"upgradeOverlayTitle": "Upgrade",
"upgradeOverlaySubtitle": "A paid plan is required to use this feature",
"upgradeOverlayGoToBilling": "Go to billing",
"billingStatusState": "Status",
"billingStatusActive": "Active",
"billingStatusPaused": "Paused",
@ -209,5 +206,21 @@
"featureRequest": "Request a Feature",
"bugReport": "Report an Issue",
"getSupport": "Get Support",
"feedback": "Feedback"
"feedback": "Feedback",
"duplicate": "Duplicate",
"duplicateDescription": "Create a new poll based on this one",
"duplicateTitleLabel": "Title",
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
"thankYou": "Thank you!",
"pleaseWait": "Your account is being upgraded. This should only take a few seconds.",
"proFeature": "Pro Feature",
"upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"noAds": "No ads",
"upgrade": "Upgrade",
"notToday": "Not Today",
"supportProject": "Support this project",
"features": "Get access to all current and future Pro features!"
}

View file

@ -8,29 +8,18 @@ import {
BillingPlanPrice,
BillingPlanTitle,
} from "@rallly/ui/billing-plan";
import { Button } from "@rallly/ui/button";
import { Label } from "@rallly/ui/label";
import { Switch } from "@rallly/ui/switch";
import { useRouter } from "next/router";
import React from "react";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { UpgradeButton } from "@/components/upgrade-button";
import { usePlan } from "@/contexts/plan";
const monthlyPriceUsd = 5;
const annualPriceUsd = 30;
const basicPlanIdMonthly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
const basicPlanIdYearly = process.env.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
export const BillingPlans = () => {
const { user } = useUser();
const router = useRouter();
const [isPendingSubscription, setPendingSubscription] = React.useState(false);
const [isBilledAnnually, setBilledAnnually] = React.useState(true);
const plan = usePlan();
const isPlus = plan === "paid";
@ -92,32 +81,9 @@ export const BillingPlans = () => {
<ProPlan annual={isBilledAnnually}>
{!isPlus ? (
<Button
className="w-full"
loading={isPendingSubscription}
variant="primary"
onClick={() => {
if (user.isGuest) {
router.push("/login");
} else {
window.Paddle.Checkout.open({
allowQuantity: false,
product: isBilledAnnually
? basicPlanIdYearly
: basicPlanIdMonthly,
email: user.email,
disableLogout: true,
passthrough: JSON.stringify({ userId: user.id }),
successCallback: () => {
// fetch user till we get the new plan
setPendingSubscription(true);
},
});
}
}}
>
<UpgradeButton annual={isBilledAnnually}>
<Trans i18nKey="planUpgrade" defaults="Upgrade" />
</Button>
</UpgradeButton>
) : null}
</ProPlan>
</div>

View file

@ -36,7 +36,10 @@ export const Changelog = ({ className }: { className?: string }) => {
<>
<FeaturebaseScript />
<Button
className={cn("[&>*]:pointer-events-none", className)}
className={cn(
"hidden sm:inline-flex [&>*]:pointer-events-none",
className,
)}
size="sm"
variant="ghost"
data-featurebase-changelog
@ -47,7 +50,7 @@ export const Changelog = ({ className }: { className?: string }) => {
</span>
<span
id="fb-update-badge"
className="bg-primary rounded-md px-1 py-px text-xs text-gray-100 empty:hidden"
className="bg-primary rounded-full px-2 py-px text-xs text-gray-100 empty:hidden"
/>
</Button>
</>

View file

@ -1,6 +1,5 @@
import { EyeIcon, MessageCircleIcon, VoteIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import {
Card,
CardContent,
@ -16,6 +15,7 @@ import React from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import { ProBadge } from "@/components/pro-badge";
import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = {
@ -58,9 +58,7 @@ export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
<CardTitle>
<Trans i18nKey="settings" />
</CardTitle>
<Badge>
<Trans i18nKey="planPro" />
</Badge>
<ProBadge />
</div>
<CardDescription>
<Trans

View file

@ -2,7 +2,6 @@ import { trpc } from "@rallly/backend";
import {
ArrowLeftIcon,
ArrowUpRight,
CheckCircleIcon,
ChevronDownIcon,
FileBarChart,
LogInIcon,
@ -18,7 +17,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIconLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import Head from "next/head";
@ -145,14 +143,6 @@ const StatusControl = () => {
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/poll/${poll.id}/finalize`}>
<DropdownMenuItemIconLabel icon={CheckCircleIcon}>
<Trans i18nKey="finishPoll" defaults="Finalize" />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
@ -168,9 +158,9 @@ const AdminControls = () => {
return (
<TopBar>
<div className="flex flex-col items-start justify-between gap-y-2 gap-x-4 sm:flex-row">
<div className="flex min-w-0 gap-2">
<div className="flex min-w-0 gap-4">
{router.asPath !== pollLink ? (
<Button variant="ghost" asChild>
<Button asChild>
<Link href={pollLink}>
<ArrowLeftIcon className="h-4 w-4" />
</Link>
@ -267,7 +257,8 @@ const Title = () => {
const Prefetch = ({ children }: React.PropsWithChildren) => {
const router = useRouter();
const [urlId] = React.useState(router.query.urlId as string);
const urlId = router.query.urlId as string;
const poll = trpc.polls.get.useQuery({ urlId });
const participants = trpc.polls.participants.list.useQuery({ pollId: urlId });

View file

@ -37,7 +37,7 @@ const NavMenuItem = ({
}) => {
const router = useRouter();
return (
<Button variant="ghost" size="sm" asChild>
<Button variant="ghost" asChild>
<Link
target={target}
href={href}
@ -69,7 +69,7 @@ const Logo = () => {
};
}, [router.events]);
return (
<div className="relative flex items-center justify-center gap-4 pr-8">
<div className="relative flex items-center justify-center gap-4">
<Link
href="/polls"
className={clsx(
@ -86,7 +86,7 @@ const Logo = () => {
</Link>
<div
className={cn(
"pointer-events-none absolute -right-0 flex items-center justify-center text-gray-500 transition-opacity delay-500",
"pointer-events-none flex w-8 items-center justify-center text-gray-500 transition-opacity delay-500",
isBusy ? "opacity-100" : "opacity-0",
)}
>
@ -103,7 +103,7 @@ const MainNav = () => {
hidden: { y: -56, opacity: 0, height: 0 },
visible: { y: 0, opacity: 1, height: "auto" },
}}
initial={"hidden"}
initial="hidden"
animate="visible"
exit={"hidden"}
className="border-b bg-gray-50/50"

View file

@ -0,0 +1,267 @@
import { trpc } from "@rallly/backend";
import {
CalendarCheck2Icon,
CopyIcon,
DatabaseIcon,
HeartIcon,
ImageOffIcon,
Loader2Icon,
LockIcon,
Settings2Icon,
TrendingUpIcon,
} from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import { m } from "framer-motion";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { Trans } from "@/components/trans";
import { UpgradeButton } from "@/components/upgrade-button";
import { usePlan } from "@/contexts/plan";
import { IconComponent } from "@/types";
const Feature = ({
icon: Icon,
children,
className,
upcoming,
}: React.PropsWithChildren<{
icon: IconComponent;
upcoming?: boolean;
className?: string;
}>) => {
return (
<li
className={cn(
"flex translate-y-0 cursor-default items-center justify-center gap-x-2.5 rounded-full border bg-gray-50 p-1 pr-4 shadow-sm transition-all hover:-translate-y-1 hover:bg-white/50",
upcoming ? "bg-transparent` border-dashed shadow-none" : "",
)}
>
<span
className={cn("bg-primary rounded-full p-1 text-gray-50", className)}
>
<Icon className="h-4 w-4" />
</span>
<div className="text-sm font-semibold">{children}</div>
</li>
);
};
const ThankYou = () => {
trpc.user.getBilling.useQuery(undefined, {
refetchInterval: 1000,
});
return (
<div className="space-y-2 text-center">
<h2>
<Trans i18nKey="thankYou" defaults="Thank you!" />
</h2>
<p className="text-muted-foreground mx-auto max-w-xs text-sm">
<Trans
i18nKey="pleaseWait"
defaults="Your account is being upgraded. This should only take a few seconds."
/>
</p>
<div className="p-4 text-gray-500">
<Loader2Icon className="inline-block h-7 w-7 animate-spin" />
</div>
</div>
);
};
const Teaser = () => {
const router = useRouter();
const [didUpgrade, setDidUpgrade] = React.useState(false);
const [tab, setTab] = React.useState("yearly");
return (
<m.div
transition={{
delay: 0.3,
duration: 1,
type: "spring",
bounce: 0.5,
}}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
className="sm:shadow-huge mx-auto w-[420px] max-w-full translate-y-0 space-y-2 rounded-md border bg-gray-50/90 p-4 shadow-sm sm:space-y-6"
>
<div className="pt-4">
<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 className="translate-y-0 py-0.5 px-4 text-lg">
<Trans i18nKey="planPro" />
</Badge>
</m.div>
</div>
{didUpgrade ? (
<ThankYou />
) : (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-center">
<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>
<Tabs
className="flex flex-col items-center gap-4"
value={tab}
onValueChange={setTab}
>
<TabsList>
<TabsTrigger value="monthly">
<Trans i18nKey="billingPeriodMonthly" />
</TabsTrigger>
<TabsTrigger value="yearly">
<Trans i18nKey="billingPeriodYearly" />
</TabsTrigger>
</TabsList>
<TabsContent value="monthly">
<div>
<div className="flex items-start justify-center gap-2.5">
<div className=" text-4xl font-bold">$5</div>
<div>
<div className="text-xs font-semibold leading-5">USD</div>
</div>
</div>
<div className="text-muted-foreground text-sm">
<Trans i18nKey="monthlyBillingDescription" />
</div>
</div>
</TabsContent>
<TabsContent value="yearly">
<div className="text-center">
<div className="flex items-start justify-center gap-2.5">
<div className="flex items-end gap-2">
<div className="font-bold text-gray-500 line-through">
$5
</div>
<div className=" text-4xl font-bold">$2.50</div>
</div>
<div>
<div className="mt-1 text-xs font-semibold">USD</div>
</div>
</div>
<div className="text-muted-foreground text-sm">
<Trans i18nKey="annualBillingDescription" />
</div>
<p className="mt-2">
<span className="rounded border border-dashed border-green-400 px-1 py-0.5 text-xs text-green-500">
<Trans
i18nKey="savePercent"
defaults="Save {percent}%"
values={{ percent: 50 }}
/>
</span>
</p>
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<p className="text-primary text-center text-xs">
<TrendingUpIcon className="mr-2 inline-block h-4 w-4" />
<Trans
i18nKey="priceIncreaseSoon"
defaults="Price increase soon."
/>
</p>
<p className="text-center text-xs text-gray-400">
<LockIcon className="mr-2 inline-block h-4 w-4" />
<Trans
i18nKey="lockPrice"
defaults="Upgrade today to keep this price forever."
/>
</p>
</div>
<h3 className="mx-auto max-w-sm text-center">
<Trans
i18nKey="features"
defaults="Get access to all current and future Pro features!"
/>
</h3>
<ul className="flex flex-wrap justify-center gap-2 border-gray-100 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-gray-100 via-transparent">
<Feature className="bg-violet-500" icon={ImageOffIcon}>
<Trans i18nKey="noAds" defaults="No ads" />
</Feature>
<Feature className="bg-rose-500" icon={DatabaseIcon}>
<Trans
i18nKey="plan_extendedPollLife"
defaults="Extend poll life"
/>
</Feature>
<Feature className="bg-green-500" icon={CalendarCheck2Icon}>
<Trans i18nKey="finalize" defaults="Finalize" />
</Feature>
<Feature className="bg-teal-500" icon={CopyIcon}>
<Trans i18nKey="duplicate" defaults="Duplicate" />
</Feature>
<Feature className="bg-gray-700" icon={Settings2Icon}>
<Trans i18nKey="settings" defaults="Settings" />
</Feature>
<Feature className="bg-pink-600" icon={HeartIcon}>
<Trans i18nKey="supportProject" defaults="Support this project" />
</Feature>
</ul>
<div className="grid gap-2.5">
<UpgradeButton
annual={tab === "yearly"}
onUpgrade={() => setDidUpgrade(true)}
>
<Trans i18nKey="upgrade" defaults="Upgrade" />
</UpgradeButton>
<Button asChild className="w-full">
<Link href={`/poll/${router.query.urlId as string}`}>
<Trans i18nKey="notToday" defaults="Not Today" />
</Link>
</Button>
</div>
</div>
)}
</m.div>
);
};
export const PayWall = ({ children }: React.PropsWithChildren) => {
const isPaid = usePlan() === "paid";
if (isPaid) {
return <>{children}</>;
}
return (
<div className="relative">
<div className="pointer-events-none absolute top-8 hidden w-full scale-90 opacity-20 blur-sm sm:block">
{children}
</div>
<div className="relative z-10 w-full">
<Teaser />
</div>
</div>
);
};
export const PayWallTeaser = ({ children }: React.PropsWithChildren) => {
return <div>{children}</div>;
};

View file

@ -1,5 +1,7 @@
import {
CalendarCheck2Icon,
ChevronDownIcon,
CopyIcon,
DownloadIcon,
PencilIcon,
Settings2Icon,
@ -20,6 +22,7 @@ import Link from "next/link";
import { Trans } from "next-i18next";
import * as React from "react";
import { ProBadge } from "@/components/pro-badge";
import { usePoll } from "@/contexts/poll";
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
@ -62,6 +65,7 @@ const ManagePoll: React.FunctionComponent<{
<Link href={`/poll/${poll.id}/edit-settings`}>
<DropdownMenuItemIconLabel icon={Settings2Icon}>
<Trans i18nKey="editSettings" defaults="Edit settings" />
<ProBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
@ -71,6 +75,23 @@ const ManagePoll: React.FunctionComponent<{
<Trans i18nKey="exportToCsv" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/poll/${poll.id}/duplicate`}>
<DropdownMenuItemIconLabel icon={CopyIcon}>
<Trans i18nKey="duplicate" defaults="Duplicate" />
<ProBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild disabled={!!poll.event}>
<Link href={`/poll/${poll.id}/finalize`}>
<DropdownMenuItemIconLabel icon={CalendarCheck2Icon}>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setShowDeletePollDialog(true);

View file

@ -0,0 +1,13 @@
import { Badge } from "@rallly/ui/badge";
import { usePlan } from "@/contexts/plan";
export const ProBadge = ({ className }: { className?: string }) => {
const isPaid = usePlan() === "paid";
if (isPaid) {
return null;
}
return <Badge className={className}>Pro</Badge>;
};

View file

@ -0,0 +1,57 @@
import { trpc } from "@rallly/backend";
import { Button } from "@rallly/ui/button";
import { useRouter } from "next/router";
import React from "react";
import { useUser } from "@/components/user-provider";
import { planIdMonthly, planIdYearly } from "@/utils/constants";
import { usePostHog } from "@/utils/posthog";
export const UpgradeButton = ({
children,
onUpgrade,
annual,
}: React.PropsWithChildren<{ annual?: boolean; onUpgrade?: () => void }>) => {
const posthog = usePostHog();
const [isPendingSubscription, setPendingSubscription] = React.useState(false);
const { user } = useUser();
const router = useRouter();
trpc.user.getBilling.useQuery(undefined, {
refetchInterval: isPendingSubscription ? 1000 : 0,
});
return (
<>
<Button
className="w-full"
loading={isPendingSubscription}
variant="primary"
onClick={() => {
posthog?.capture("click upgrade button");
if (user.isGuest) {
router.push("/login");
} else {
window.Paddle.Checkout.open({
allowQuantity: false,
product: annual ? planIdYearly : planIdMonthly,
email: user.email,
disableLogout: true,
passthrough: JSON.stringify({ userId: user.id }),
successCallback: () => {
posthog?.capture("upgrade", {
period: annual ? "yearly" : "monthly",
});
onUpgrade?.();
// fetch user till we get the new plan
setPendingSubscription(true);
},
});
}
}}
>
{children}
</Button>
</>
);
};

View file

@ -68,6 +68,7 @@ interface SubscriptionPaymentSucceededRequest extends BasePaddleRequest {
receipt_url: string;
sale_gross: string;
next_bill_date: string;
initial_payment: string;
}
interface SubscriptionPaymentFailedRequest extends BasePaddleRequest {

View file

@ -10,6 +10,7 @@ import { NextPage } from "next";
import { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import Script from "next/script";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo";
import React from "react";
@ -76,6 +77,19 @@ const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
/>
</Head>
{process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID ? (
<Script
src="https://cdn.paddle.com/paddle/paddle.js"
onLoad={() => {
if (process.env.NEXT_PUBLIC_PADDLE_SANDBOX === "true") {
window.Paddle.Environment.set("sandbox");
}
window.Paddle.Setup({
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
});
}}
/>
) : null}
<style jsx global>{`
html {
--font-inter: ${inter.style.fontFamily};

View file

@ -144,6 +144,10 @@ export default async function handler(
}
case "subscription_payment_succeeded":
// Handle successful payment
// This event is triggered before subscription_created which means
// the row has not been created yet. If the subscription is renewed inital_payment
// won't be "1" and we can update the row.
if (payload.initial_payment !== "1") {
await prisma.userPaymentData.update({
where: {
userId: passthrough.userId,
@ -153,6 +157,7 @@ export default async function handler(
endDate: new Date(payload.next_bill_date),
},
});
}
break;
case "subscription_payment_failed":
await prisma.userPaymentData.update({

View file

@ -0,0 +1,126 @@
import { trpc } from "@rallly/backend";
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/router";
import { useForm } from "react-hook-form";
import { getPollLayout } from "@/components/layouts/poll-layout";
import { PayWall } from "@/components/pay-wall";
import { usePoll } from "@/components/poll-context";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/with-page-translations";
const Page: NextPageWithLayout = () => {
const { poll } = usePoll();
const duplicate = trpc.polls.duplicate.useMutation();
const router = useRouter();
const pollLink = `/poll/${poll.id}`;
const form = useForm<{ title: string }>({
defaultValues: {
title: poll.title,
},
});
return (
<PayWall>
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit((data) => {
//submit
duplicate.mutate(
{ pollId: poll.id, newTitle: data.title },
{
onSuccess: async (res) => {
await 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 {...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>
);
};
Page.getLayout = getPollLayout;
export const getStaticPaths = async () => {
return {
paths: [],
fallback: "blocking",
};
};
export const getStaticProps = getStaticTranslations;
export default Page;

View file

@ -10,6 +10,7 @@ import {
PollSettingsFormData,
} from "@/components/forms/poll-settings";
import { getPollLayout } from "@/components/layouts/poll-layout";
import { PayWall } from "@/components/pay-wall";
import { useUpdatePollMutation } from "@/components/poll/mutations";
import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll";
@ -38,6 +39,7 @@ const Page: NextPageWithLayout = () => {
});
return (
<PayWall>
<Form {...form}>
<form
className="mx-auto max-w-3xl"
@ -65,6 +67,7 @@ const Page: NextPageWithLayout = () => {
</PollSettingsForm>
</form>
</Form>
</PayWall>
);
};

View file

@ -1,5 +1,4 @@
import { trpc } from "@rallly/backend";
import { LockIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import {
CardContent,
@ -8,12 +7,11 @@ import {
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import Link from "next/link";
import { useRouter } from "next/router";
import { ProPlan } from "@/components/billing/billing-plans";
import { Card } from "@/components/card";
import { getPollLayout } from "@/components/layouts/poll-layout";
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";
@ -88,52 +86,12 @@ const FinalizationForm = () => {
);
};
const Teaser = () => {
return (
<div className="relative mx-auto max-w-3xl">
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-white/10 backdrop-blur-sm">
<div className="shadow-huge space-y-4 overflow-hidden rounded-md bg-white p-4">
<div className="flex gap-x-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-50 shadow-sm">
<LockIcon className="text-primary h-5 w-5" />
</div>
<div>
<h2 className="text-base font-semibold leading-tight">
<Trans i18nKey="upgradeOverlayTitle" defaults="Upgrade" />
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
<Trans
i18nKey="upgradeOverlaySubtitle"
defaults="A paid plan is required to use this feature"
/>
</p>
</div>
</div>
<ProPlan annual={true}>
<Button variant="primary" asChild className="w-full">
<Link href="/settings/billing">
<Trans
i18nKey="upgradeOverlayGoToBilling"
defaults="Go to billing"
/>
</Link>
</Button>
</ProPlan>
</div>
</div>
<FinalizationForm />
</div>
);
};
const Page: NextPageWithLayout = () => {
const plan = usePlan();
if (plan === "paid") {
return <FinalizationForm />;
}
return <Teaser />;
return (
<PayWall>
<FinalizationForm />
</PayWall>
);
};
Page.getLayout = getPollLayout;

View file

@ -6,7 +6,6 @@ import { Label } from "@rallly/ui/label";
import dayjs from "dayjs";
import Head from "next/head";
import Link from "next/link";
import Script from "next/script";
import { useTranslation } from "next-i18next";
import { BillingPlans } from "@/components/billing/billing-plans";
@ -35,10 +34,6 @@ export const proPlanIdYearly = process.env
const SubscriptionStatus = () => {
const { user } = useUser();
trpc.user.getBilling.useQuery(undefined, {
refetchInterval: 5000,
});
const plan = usePlan();
const isPlus = plan === "paid";
@ -175,17 +170,6 @@ const Page: NextPageWithLayout = () => {
<Head>
<title>{t("billing")}</title>
</Head>
<Script
src="https://cdn.paddle.com/paddle/paddle.js"
onLoad={() => {
if (process.env.NEXT_PUBLIC_PADDLE_SANDBOX === "true") {
window.Paddle.Environment.set("sandbox");
}
window.Paddle.Setup({
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
});
}}
/>
<SettingsSection
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
description={

View file

@ -1,8 +1,8 @@
export const plusPlanIdMonthly = process.env
export const planIdMonthly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
export const plusPlanIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
export const planIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
export const isFeedbackEnabled =
process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === "true";

View file

@ -11,7 +11,12 @@ import { z } from "zod";
import { getTimeZoneAbbreviation } from "../../utils/date";
import { nanoid } from "../../utils/nanoid";
import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
import {
possiblyPublicProcedure,
proProcedure,
publicProcedure,
router,
} from "../trpc";
import { comments } from "./polls/comments";
import { demo } from "./polls/demo";
import { options } from "./polls/options";
@ -505,7 +510,7 @@ export const polls = router({
return polls;
}),
book: possiblyPublicProcedure
book: proProcedure
.input(
z.object({
pollId: z.string(),
@ -785,6 +790,67 @@ export const polls = router({
},
});
}),
duplicate: proProcedure
.input(
z.object({
pollId: z.string(),
newTitle: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const poll = await prisma.poll.findUnique({
where: {
id: input.pollId,
},
select: {
location: true,
description: true,
timeZone: true,
hideParticipants: true,
hideScores: true,
disableComments: true,
options: {
select: {
start: true,
duration: true,
},
},
},
});
if (!poll) {
throw new TRPCError({ code: "NOT_FOUND", message: "Poll not found" });
}
const newPoll = await prisma.poll.create({
select: {
id: true,
},
data: {
id: nanoid(),
title: input.newTitle,
userId: ctx.user.id,
timeZone: poll.timeZone,
location: poll.location,
description: poll.description,
hideParticipants: poll.hideParticipants,
hideScores: poll.hideScores,
disableComments: poll.disableComments,
adminUrlId: nanoid(),
participantUrlId: nanoid(),
watchers: {
create: {
userId: ctx.user.id,
},
},
options: {
create: poll.options,
},
},
});
return newPoll;
}),
resume: possiblyPublicProcedure
.input(
z.object({

View file

@ -1,3 +1,4 @@
import { prisma } from "@rallly/database";
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
@ -28,6 +29,38 @@ export const possiblyPublicProcedure = t.procedure.use(
}),
);
export const proProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
});
}
const isPro = Boolean(
await prisma.userPaymentData.findFirst({
where: {
userId: ctx.user.id,
endDate: {
gt: new Date(),
},
},
}),
);
if (!isPro) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You must have an active paid subscription to perform this action",
});
}
return next();
}),
);
export const privateProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (ctx.user.isGuest) {

View file

@ -6,6 +6,6 @@
"types": "src/index.ts",
"dependencies": {
"@heroicons/react": "^1.0.6",
"lucide-react": "^0.233.0"
"lucide-react": "^0.265.0"
}
}

View file

@ -4,7 +4,7 @@ import * as React from "react";
import { cn } from "./lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {

View file

@ -1,44 +0,0 @@
"use client";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "@rallly/icons";
import * as React from "react";
import { cn } from "./lib/utils";
const ButtonGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
ButtonGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"border-border bg-background text-primary ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View file

@ -207,9 +207,9 @@ export const DropdownMenuItemIconLabel = ({
children: React.ReactNode;
}) => {
return (
<>
<Icon className="mr-2 h-4 w-4" />
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{children}
</>
</span>
);
};

View file

@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex h-9 items-center justify-center whitespace-nowrap rounded px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:ring-1 data-[state=active]:ring-gray-200",
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:ring-1 data-[state=active]:ring-gray-200",
className,
)}
{...props}

View file

@ -7283,10 +7283,10 @@ lru_map@^0.3.3:
resolved "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz"
integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==
lucide-react@^0.233.0:
version "0.233.0"
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.233.0.tgz"
integrity sha512-r0jMHF0vPDq2wBbZ0B3rtIcBjDyWDKpHu+vAjD2OHn2WLUr3HN5IHovtO0EMgQXuSI7YrMZbjsEZWC2uBHr8nQ==
lucide-react@^0.265.0:
version "0.265.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.265.0.tgz#251558b65aa24d069171b4e2b4846af37f5cc105"
integrity sha512-znyvziBEUQ7CKR31GiU4viomQbJrpDLG5ac+FajwiZIavC3YbPFLkzQx3dCXT4JWJx/pB34EwmtiZ0ElGZX0PA==
luxon@^3.2.1:
version "3.2.1"