mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-05 12:11:51 +02:00
✨ Allow duplicating a poll (#804)
This commit is contained in:
parent
f68579eef2
commit
996743caaf
27 changed files with 699 additions and 224 deletions
|
@ -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);
|
||||
|
||||
|
|
|
@ -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!"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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"
|
||||
|
|
267
apps/web/src/components/pay-wall.tsx
Normal file
267
apps/web/src/components/pay-wall.tsx
Normal 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>;
|
||||
};
|
|
@ -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);
|
||||
|
|
13
apps/web/src/components/pro-badge.tsx
Normal file
13
apps/web/src/components/pro-badge.tsx
Normal 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>;
|
||||
};
|
57
apps/web/src/components/upgrade-button.tsx
Normal file
57
apps/web/src/components/upgrade-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -144,15 +144,20 @@ export default async function handler(
|
|||
}
|
||||
case "subscription_payment_succeeded":
|
||||
// Handle successful payment
|
||||
await prisma.userPaymentData.update({
|
||||
where: {
|
||||
userId: passthrough.userId,
|
||||
},
|
||||
data: {
|
||||
status: payload.status,
|
||||
endDate: new Date(payload.next_bill_date),
|
||||
},
|
||||
});
|
||||
// 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,
|
||||
},
|
||||
data: {
|
||||
status: payload.status,
|
||||
endDate: new Date(payload.next_bill_date),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "subscription_payment_failed":
|
||||
await prisma.userPaymentData.update({
|
||||
|
|
126
apps/web/src/pages/poll/[urlId]/duplicate.tsx
Normal file
126
apps/web/src/pages/poll/[urlId]/duplicate.tsx
Normal 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;
|
|
@ -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,33 +39,35 @@ const Page: NextPageWithLayout = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
//submit
|
||||
await update.mutateAsync(
|
||||
{ urlId: poll.adminUrlId, ...data },
|
||||
{
|
||||
onSuccess: redirectBackToPoll,
|
||||
},
|
||||
);
|
||||
})}
|
||||
>
|
||||
<PollSettingsForm>
|
||||
<CardFooter className="justify-between">
|
||||
<Button asChild>
|
||||
<Link href={pollLink}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</PollSettingsForm>
|
||||
</form>
|
||||
</Form>
|
||||
<PayWall>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
//submit
|
||||
await update.mutateAsync(
|
||||
{ urlId: poll.adminUrlId, ...data },
|
||||
{
|
||||
onSuccess: redirectBackToPoll,
|
||||
},
|
||||
);
|
||||
})}
|
||||
>
|
||||
<PollSettingsForm>
|
||||
<CardFooter className="justify-between">
|
||||
<Button asChild>
|
||||
<Link href={pollLink}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</PollSettingsForm>
|
||||
</form>
|
||||
</Form>
|
||||
</PayWall>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -6,6 +6,6 @@
|
|||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"lucide-react": "^0.233.0"
|
||||
"lucide-react": "^0.265.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue