mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-05 12:11:51 +02:00
💵 Add ability to accept payments (#722)
This commit is contained in:
parent
1c7c8c7678
commit
2ccf705a2c
44 changed files with 2021 additions and 604 deletions
|
@ -49,5 +49,30 @@
|
|||
"homepage_readDocs": "Read the docs",
|
||||
"goToApp": "Go to app",
|
||||
"blogTitle": "Rallly - Blog",
|
||||
"blogDescription": "News, updates and announcement about Rallly."
|
||||
"blogDescription": "News, updates and announcement about Rallly.",
|
||||
"pricing": "Pricing",
|
||||
"annualBilling": "Annual billing (Save {discount}%)",
|
||||
"planFree": "Free",
|
||||
"freeForever": "free forever",
|
||||
"plan_unlimitedPolls": "Unlimited polls",
|
||||
"plan_unlimitedParticipants": "Unlimited participants",
|
||||
"upgrade": "Upgrade",
|
||||
"planPro": "Pro",
|
||||
"annualBillingDescription": "per month, billed annually",
|
||||
"monthlyBillingDescription": "per month",
|
||||
"plan_finalizePolls": "Finalize polls",
|
||||
"plan_extendedPollLife": "Extended poll life",
|
||||
"plan_prioritySupport": "Priority support",
|
||||
"pricingDescription": "Get started for free. No login required.",
|
||||
"getStarted": "Get started for free",
|
||||
"faq": "Frequently Asked Questions",
|
||||
"faq_canUseFree": "Can I use Rallly for free?",
|
||||
"faq_canUseFreeAnswer": "Yes, as a free user you can create polls and get insight into your participants' availability. You will still see the results of your poll but you won't be able to select a final date or send calendar invites.",
|
||||
"faq_whyUpgrade": "Why should I upgrade?",
|
||||
"faq_whyUpgradeAnswer": "When you upgrade to a paid plan, you will be able to finalize your polls and automatically send calendar invites to your participants with your selected date. We will also keep your polls indefinitely so they won't be automatically deleted even after they are finalized.",
|
||||
"faq_howToUpgrade": "How do I upgrade to a paid plan?",
|
||||
"faq_howToUpgradeAnswer": "To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>.",
|
||||
"faq_cancelSubscription": "How do I cancel my subscription?",
|
||||
"faq_cancelSubscriptionAnswer": "You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan.",
|
||||
"priceIncreaseInfo": "Prices will be adjusted regularly as more features are added"
|
||||
}
|
||||
|
|
|
@ -5,12 +5,11 @@ import React from "react";
|
|||
import Bonus from "./home/bonus";
|
||||
import Features from "./home/features";
|
||||
import Hero from "./home/hero";
|
||||
import PageLayout from "./layouts/page-layout";
|
||||
|
||||
const Home: React.FunctionComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-24">
|
||||
<NextSeo
|
||||
title={t("homepage_metaTitle")}
|
||||
description={t("homepage_metaDescription")}
|
||||
|
@ -23,7 +22,7 @@ const Home: React.FunctionComponent = () => {
|
|||
<Hero />
|
||||
<Features />
|
||||
<Bonus />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import Ban from "./ban-ads.svg";
|
|||
const Bonus: React.FunctionComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl py-12">
|
||||
<div>
|
||||
<h2 className="heading">{t("homepage_principles")}</h2>
|
||||
<p className="subheading">{t("homepage_principlesSubheading")}</p>
|
||||
<div className="grid grid-cols-4 gap-16">
|
||||
|
|
|
@ -10,41 +10,55 @@ import * as React from "react";
|
|||
const Features: React.FunctionComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl py-16 sm:py-24">
|
||||
<div>
|
||||
<h2 className="heading text-center">{t("homepage_features")}</h2>
|
||||
<p className="subheading text-center">
|
||||
{t("homepage_featuresSubheading")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-12">
|
||||
<div className="grid grid-cols-2 gap-x-16 gap-y-8">
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="bg-primary-500 mb-4 inline-block rounded-md p-3 text-slate-50">
|
||||
<ClockIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="heading-sm flex items-center">
|
||||
<h3 className="mb-2 text-lg tracking-tight">
|
||||
{t("homepage_timeSlots")}
|
||||
</h3>
|
||||
<p className="text">{t("homepage_timeSlotsDescription")}</p>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
{t("homepage_timeSlotsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4 inline-block rounded-md bg-cyan-500 p-3 text-slate-50">
|
||||
<SmartphoneIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="heading-sm">{t("homepage_mobileFriendly")}</h3>
|
||||
<p className="text">{t("homepage_mobileFriendlyDescription")}</p>
|
||||
<h3 className="mb-2 text-lg tracking-tight">
|
||||
{t("homepage_mobileFriendly")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
{t("homepage_mobileFriendlyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4 inline-block rounded-md bg-violet-500 p-3 text-slate-50">
|
||||
<BellIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="heading-sm">{t("homepage_notifications")}</h3>
|
||||
<p className="text">{t("homepage_notificationsDescription")}</p>
|
||||
<h3 className="mb-2 text-lg tracking-tight">
|
||||
{t("homepage_notifications")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
{t("homepage_notificationsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4 inline-block rounded-md bg-pink-500 p-3 text-slate-50">
|
||||
<MessageCircleIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="heading-sm">{t("homepage_comments")}</h3>
|
||||
<p className="text">{t("homepage_commentsDescription")}</p>
|
||||
<h3 className="mb-2 text-lg tracking-tight">
|
||||
{t("homepage_comments")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
{t("homepage_commentsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,7 @@ import * as React from "react";
|
|||
|
||||
import { getRandomAvatarColor } from "@/components/home/color-hash";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { linkToApp } from "@/lib/linkToApp";
|
||||
|
||||
const VoteIcon = ({ variant }: { variant: VoteType }) => {
|
||||
return (
|
||||
|
@ -108,8 +109,8 @@ const participants: Array<{ name: string; votes: VoteType[] }> = [
|
|||
];
|
||||
const Demo = () => {
|
||||
return (
|
||||
<div className="sm:shadow-huge min-w-0 max-w-full select-none overflow-x-auto rounded-md border border-gray-300/75 bg-white pb-1">
|
||||
<div className="sticky left-0 flex h-14 items-center justify-between border-b bg-gray-50 px-4 py-3 font-semibold">
|
||||
<div className="sm:shadow-huge w-[700px] min-w-0 shrink-0 select-none overflow-hidden rounded-md border border-gray-300/75 bg-white pb-1">
|
||||
<div className="flex h-14 items-center justify-between border-b bg-gray-50 px-4 py-3 font-semibold">
|
||||
<div>
|
||||
<Trans i18nKey="participantCount" values={{ count: 5 }} />
|
||||
</div>
|
||||
|
@ -175,13 +176,12 @@ const Demo = () => {
|
|||
|
||||
const Hero: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mt-8 text-center sm:mt-16">
|
||||
<div className="mt-8 max-w-full text-center sm:mt-16">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://github.com/lukevella/rallly"
|
||||
className="hover:text-primary hover:border-primary active:bg-primary-50 group inline-flex items-center rounded-full border bg-gray-50/50 px-2.5 py-2 text-xs font-medium text-gray-600 transition-colors sm:text-sm"
|
||||
className="hover:text-primary hover:border-primary active:bg-primary-50 group inline-flex items-center rounded-full border border-gray-300/50 bg-gray-50/50 px-2.5 py-2 text-xs font-medium text-gray-600 transition-colors sm:text-sm"
|
||||
>
|
||||
<span className="px-2.5">
|
||||
<Trans i18nKey="opensource" defaults="We're Open Source!" />
|
||||
|
@ -194,10 +194,7 @@ const Hero: React.FunctionComponent = () => {
|
|||
</Link>
|
||||
</div>
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-gray-800 sm:text-5xl">
|
||||
<Trans
|
||||
i18nKey="headline"
|
||||
defaults="Ditch the back-and-forth emails"
|
||||
/>
|
||||
<Trans i18nKey="headline" defaults="Ditch the back-and-forth emails" />
|
||||
</h1>
|
||||
<p className="text-xl text-gray-500">
|
||||
<Trans
|
||||
|
@ -208,7 +205,7 @@ const Hero: React.FunctionComponent = () => {
|
|||
<div className="mt-8 flex justify-center gap-3">
|
||||
<div className="relative">
|
||||
<Button size="lg" className="shadow-sm" variant="primary" asChild>
|
||||
<Link href="https://app.rallly.co/new">
|
||||
<Link href={linkToApp("/new")}>
|
||||
<Trans i18nKey="homepage_getStarted" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
@ -220,19 +217,16 @@ const Hero: React.FunctionComponent = () => {
|
|||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<p className="text-muted-foreground mb-8 whitespace-nowrap text-center text-sm">
|
||||
<p className="text-muted-foreground mt-8 mb-8 whitespace-nowrap text-center text-sm">
|
||||
<Trans
|
||||
i18nKey="getStartedHint"
|
||||
defaults="Create a poll. It's free. No login required."
|
||||
/>
|
||||
</p>
|
||||
<div className="mx-auto max-w-[700px]">
|
||||
<div className="-mx-4 flex overflow-x-auto p-4 sm:justify-center sm:overflow-visible">
|
||||
<Demo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -32,7 +32,10 @@ export const BlogLayout = ({ children }: React.PropsWithChildren) => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex sm:ml-11">
|
||||
<div className="ml-embedded w-96 p-0" data-form="h9YecB" />
|
||||
<div
|
||||
className="ml-embedded min-h-[88px] w-96 p-0"
|
||||
data-form="h9YecB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
import {
|
||||
ArrowRightIcon,
|
||||
GemIcon,
|
||||
LifeBuoyIcon,
|
||||
LogInIcon,
|
||||
MenuIcon,
|
||||
NewspaperIcon,
|
||||
} from "@rallly/icons";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@rallly/ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
@ -14,6 +21,8 @@ import { useRouter } from "next/router";
|
|||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
|
||||
import { linkToApp } from "@/lib/linkToApp";
|
||||
|
||||
import Footer from "./page-layout/footer";
|
||||
|
||||
export interface PageLayoutProps {
|
||||
|
@ -45,6 +54,9 @@ const Menu: React.FunctionComponent<{ className: string }> = ({
|
|||
return (
|
||||
<nav className={className}>
|
||||
<NavLink href="/blog">{t("common_blog")}</NavLink>
|
||||
<NavLink href="/pricing">
|
||||
<Trans i18nKey="pricing">Pricing</Trans>
|
||||
</NavLink>
|
||||
<NavLink href="https://support.rallly.co">{t("common_support")}</NavLink>
|
||||
</nav>
|
||||
);
|
||||
|
@ -76,7 +88,8 @@ const PageLayout: React.FunctionComponent<PageLayoutProps> = ({ children }) => {
|
|||
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center p-6 sm:p-8">
|
||||
<div className="mx-auto max-w-full grow p-4 sm:max-w-7xl sm:p-8">
|
||||
<div className="mb-16 flex w-full items-center">
|
||||
<div className="flex grow items-center gap-x-12">
|
||||
<Link className="inline-block rounded" href="/">
|
||||
<Image src="/logo.svg" width={130} height={30} alt="rallly.co" />
|
||||
|
@ -91,7 +104,7 @@ const PageLayout: React.FunctionComponent<PageLayoutProps> = ({ children }) => {
|
|||
<Trans i18nKey="login" defaults="Login" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://app.rallly.co"
|
||||
href={linkToApp()}
|
||||
className="bg-primary hover:bg-primary-500 active:bg-primary-700 group inline-flex items-center gap-2 rounded-full py-1.5 pl-4 pr-3 text-sm font-medium text-white shadow-sm transition-transform"
|
||||
>
|
||||
<span>
|
||||
|
@ -100,23 +113,30 @@ const PageLayout: React.FunctionComponent<PageLayoutProps> = ({ children }) => {
|
|||
<ArrowRightIcon className="inline-block h-4 w-4 -translate-x-1 transition-all group-hover:translate-x-0 group-active:translate-x-1" />
|
||||
</Link>
|
||||
<div className="flex items-center justify-center sm:hidden">
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MenuIcon className="h-6 w-6" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
sideOffset={20}
|
||||
collisionPadding={16}
|
||||
align="end"
|
||||
className="w-[var(--radix-popover-content-available-width)] bg-white/90 p-4 backdrop-blur-md"
|
||||
>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={16}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="flex items-center gap-3 p-2 text-lg"
|
||||
href="https://rallly.co/blog"
|
||||
href="/blog"
|
||||
>
|
||||
<NewspaperIcon className="h-5 w-5" />
|
||||
<Trans i18nKey="common_blog" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="flex items-center gap-3 p-2 text-lg"
|
||||
href="/pricing"
|
||||
>
|
||||
<GemIcon className="h-5 w-5" />
|
||||
<Trans i18nKey="pricing" defaults="Pricing" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="flex items-center gap-3 p-2 text-lg"
|
||||
href="https://support.rallly.co"
|
||||
|
@ -124,22 +144,28 @@ const PageLayout: React.FunctionComponent<PageLayoutProps> = ({ children }) => {
|
|||
<LifeBuoyIcon className="h-5 w-5" />
|
||||
<Trans i18nKey="common_support" />
|
||||
</Link>
|
||||
<hr className="my-2" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className="flex items-center gap-3 p-2 text-lg"
|
||||
href="https://app.rallly.co/login"
|
||||
href={linkToApp("/login")}
|
||||
>
|
||||
<LogInIcon className="h-5 w-5" />
|
||||
<Trans i18nKey="login" defaults="Login" />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-7xl grow p-6 sm:p-8">{children}</div>
|
||||
<div className="grow">{children}</div>
|
||||
<div className="pt-36">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -51,8 +51,7 @@ export const LanguageSelect = () => {
|
|||
|
||||
const Footer: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="mt-8 bg-gradient-to-b from-gray-50/0 via-gray-50 to-gray-50 ">
|
||||
<div className="mx-auto max-w-7xl space-y-8 p-8">
|
||||
<div className="mx-auto space-y-8">
|
||||
<div className="space-y-8 lg:flex lg:space-x-16 lg:space-y-0">
|
||||
<div className=" lg:w-2/6">
|
||||
<Logo className="w-32 text-gray-500" />
|
||||
|
@ -223,7 +222,6 @@ const Footer: React.FunctionComponent = () => {
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
159
apps/landing/src/components/pricing/plans.tsx
Normal file
159
apps/landing/src/components/pricing/plans.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { CheckIcon } from "@rallly/icons";
|
||||
import { Badge } from "@rallly/ui/badge";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Card } from "@rallly/ui/card";
|
||||
import { Label } from "@rallly/ui/label";
|
||||
import { Switch } from "@rallly/ui/switch";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { linkToApp } from "@/lib/linkToApp";
|
||||
|
||||
const monthlyPriceUsd = 5;
|
||||
const annualPriceUsd = 30;
|
||||
|
||||
export const BillingPlans = () => {
|
||||
const [isBilledAnnually, setBilledAnnually] = React.useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="flex items-center gap-2.5 p-4">
|
||||
<Switch
|
||||
id="annual-switch"
|
||||
checked={isBilledAnnually}
|
||||
onCheckedChange={(checked) => {
|
||||
setBilledAnnually(checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="annual-switch">
|
||||
<Trans
|
||||
i18nKey="annualBilling"
|
||||
defaults="Annual billing (Save {discount}%)"
|
||||
values={{
|
||||
discount: Math.round(100 - (annualPriceUsd / 12 / 5) * 100),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</Card>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card className="flex flex-col divide-y">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center gap-x-4">
|
||||
<Badge variant="secondary">
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-3xl font-bold">$0</span>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans i18nKey="freeForever" defaults="free forever" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col p-4">
|
||||
<ul className="text-muted-foreground grow text-sm">
|
||||
<Perk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedPolls"
|
||||
defaults="Unlimited polls"
|
||||
/>
|
||||
</Perk>
|
||||
<Perk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</Perk>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
<ProPlan annual={isBilledAnnually}>
|
||||
<Button className="mt-4 w-full" variant="primary" asChild>
|
||||
<Link href={linkToApp("/settings/billing")}>
|
||||
<Trans i18nKey="upgrade">Upgrade</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</ProPlan>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Perk = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<li className="flex">
|
||||
<CheckIcon className="mr-2 inline h-4 w-4 translate-y-0.5 -translate-x-0.5 text-green-600" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProPlan = ({
|
||||
annual,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
annual?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<Card className="border-primary ring-primary divide-y ring-1">
|
||||
<div className="bg-pattern p-4">
|
||||
<div className="mb-4 flex items-center gap-x-4">
|
||||
<Badge>
|
||||
<Trans i18nKey="planPro" defaults="Pro" />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{annual ? (
|
||||
<div>
|
||||
<span className="mr-2 text-xl font-bold line-through">
|
||||
${monthlyPriceUsd}
|
||||
</span>
|
||||
<span className="text-3xl font-bold">
|
||||
${(annualPriceUsd / 12).toFixed(2)}
|
||||
</span>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans
|
||||
i18nKey="annualBillingDescription"
|
||||
defaults="per month, billed annually"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-3xl font-bold">$5</span>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans i18nKey="monthlyBillingDescription" defaults="per month" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ul className="text-muted-foreground text-sm">
|
||||
<Perk>
|
||||
<Trans i18nKey="plan_unlimitedPolls" defaults="Unlimited polls" />
|
||||
</Perk>
|
||||
<Perk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</Perk>
|
||||
<Perk>
|
||||
<Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" />
|
||||
</Perk>
|
||||
<Perk>
|
||||
<Trans
|
||||
i18nKey="plan_extendedPollLife"
|
||||
defaults="Extended poll life"
|
||||
/>
|
||||
</Perk>
|
||||
<Perk>
|
||||
<Trans i18nKey="plan_prioritySupport" defaults="Priority support" />
|
||||
</Perk>
|
||||
</ul>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
3
apps/landing/src/lib/linkToApp.ts
Normal file
3
apps/landing/src/lib/linkToApp.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const linkToApp = (path?: string) => {
|
||||
return process.env.NEXT_PUBLIC_APP_BASE_URL + (path ? path : "");
|
||||
};
|
|
@ -1,8 +1,14 @@
|
|||
import Home from "@/components/home";
|
||||
import { getPageLayout } from "@/components/layouts/page-layout";
|
||||
import { NextPageWithLayout } from "@/types";
|
||||
import { getStaticTranslations } from "@/utils/page-translations";
|
||||
|
||||
export default function Page() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return <Home />;
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = getPageLayout;
|
||||
|
||||
export default Page;
|
||||
|
||||
export const getStaticProps = getStaticTranslations;
|
||||
|
|
271
apps/landing/src/pages/pricing.tsx
Normal file
271
apps/landing/src/pages/pricing.tsx
Normal file
|
@ -0,0 +1,271 @@
|
|||
import { CheckIcon, InfoIcon } from "@rallly/icons";
|
||||
import {
|
||||
BillingPlan,
|
||||
BillingPlanFooter,
|
||||
BillingPlanHeader,
|
||||
BillingPlanPeriod,
|
||||
BillingPlanPerk,
|
||||
BillingPlanPerks,
|
||||
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 Link from "next/link";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { NextSeo } from "next-seo";
|
||||
import React from "react";
|
||||
|
||||
import { getPageLayout } from "@/components/layouts/page-layout";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { linkToApp } from "@/lib/linkToApp";
|
||||
import { NextPageWithLayout } from "@/types";
|
||||
import { getStaticTranslations } from "@/utils/page-translations";
|
||||
|
||||
const Perk = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<li className="flex">
|
||||
<CheckIcon className="mr-2 inline h-4 w-4 translate-y-0.5 -translate-x-0.5 text-green-600" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const monthlyPriceUsd = 5;
|
||||
const annualPriceUsd = 30;
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const [annualBilling, setAnnualBilling] = React.useState(true);
|
||||
return (
|
||||
<div className="mx-auto bg-gray-100">
|
||||
<NextSeo title={t("pricing", { defaultValue: "Pricing" })} />
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight">
|
||||
<Trans i18nKey="pricing">Pricing</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
<Trans
|
||||
i18nKey="pricingDescription"
|
||||
defaults="Get started for free. No login required."
|
||||
/>
|
||||
</p>
|
||||
<div className="my-8">
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Switch
|
||||
id="annual-billing"
|
||||
checked={annualBilling}
|
||||
onCheckedChange={setAnnualBilling}
|
||||
/>
|
||||
<Label htmlFor="annual-billing">
|
||||
<Trans
|
||||
i18nKey="annualBilling"
|
||||
values={{ discount: 50 }}
|
||||
defaults="Annual billing (Save {discount}%)"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<BillingPlan>
|
||||
<BillingPlanHeader>
|
||||
<BillingPlanTitle>
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
</BillingPlanTitle>
|
||||
<BillingPlanPrice>$0</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans i18nKey="freeForever" defaults="free forever" />
|
||||
</BillingPlanPeriod>
|
||||
</BillingPlanHeader>
|
||||
<BillingPlanPerks>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedPolls"
|
||||
defaults="Unlimited polls"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
</BillingPlanPerks>
|
||||
<BillingPlanFooter>
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={linkToApp()}>
|
||||
<Trans i18nKey="getStarted">Get started for free</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</BillingPlanFooter>
|
||||
</BillingPlan>
|
||||
<BillingPlan variant="primary">
|
||||
<BillingPlanHeader>
|
||||
<BillingPlanTitle className="text-primary m-0">
|
||||
<Trans i18nKey="planPro" defaults="Pro" />
|
||||
</BillingPlanTitle>
|
||||
{annualBilling ? (
|
||||
<>
|
||||
<BillingPlanPrice
|
||||
discount={`$${(annualPriceUsd / 12).toFixed(2)}`}
|
||||
>
|
||||
${monthlyPriceUsd}
|
||||
</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans
|
||||
i18nKey="annualBillingDescription"
|
||||
defaults="per month, billed annually"
|
||||
/>
|
||||
</BillingPlanPeriod>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BillingPlanPrice>${monthlyPriceUsd}</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans
|
||||
i18nKey="monthlyBillingDescription"
|
||||
defaults="per month"
|
||||
/>
|
||||
</BillingPlanPeriod>
|
||||
</>
|
||||
)}
|
||||
</BillingPlanHeader>
|
||||
<BillingPlanPerks>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedPolls"
|
||||
defaults="Unlimited polls"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<Perk>
|
||||
<Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" />
|
||||
</Perk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_extendedPollLife"
|
||||
defaults="Extended poll life"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_prioritySupport"
|
||||
defaults="Priority support"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
</BillingPlanPerks>
|
||||
<BillingPlanFooter>
|
||||
<Button variant="primary" className="w-full" asChild>
|
||||
<Link href={linkToApp("/settings/billing")}>
|
||||
<Trans i18nKey="upgrade">Upgrade</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</BillingPlanFooter>
|
||||
</BillingPlan>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-8 flex flex-col gap-x-2 gap-y-2 text-sm sm:flex-row sm:items-center sm:justify-center sm:text-center">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<Trans i18nKey="priceIncreaseInfo">
|
||||
Prices will be adjusted regularly as more features are added
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<hr className="my-8" />
|
||||
<div>
|
||||
<h2 className="mb-4">
|
||||
<Trans i18nKey="faq" defaults="Frequently Asked Questions"></Trans>
|
||||
</h2>
|
||||
<div className="divide-y">
|
||||
<div className="grid gap-x-8 gap-y-2 py-4 md:grid-cols-3">
|
||||
<h3 className="col-span-1">
|
||||
<Trans
|
||||
i18nKey="faq_canUseFree"
|
||||
defaults="Can I use Rallly for free?"
|
||||
></Trans>
|
||||
</h3>
|
||||
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
|
||||
<Trans
|
||||
i18nKey="faq_canUseFreeAnswer"
|
||||
components={{
|
||||
a: (
|
||||
<Link
|
||||
className="text-link"
|
||||
href="https://app.rallly.co/settings/billing"
|
||||
/>
|
||||
),
|
||||
b: <strong />,
|
||||
}}
|
||||
defaults="Yes, as a free user you can create polls and get insight into your participant's availability. You will still see the results of your poll but you won't be able to select a final date or send calendar invites."
|
||||
></Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-x-8 gap-y-2 py-4 md:grid-cols-3">
|
||||
<h3 className="col-span-1">
|
||||
<Trans
|
||||
i18nKey="faq_whyUpgrade"
|
||||
defaults="Why should I upgrade?"
|
||||
></Trans>
|
||||
</h3>
|
||||
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
|
||||
<Trans
|
||||
i18nKey="faq_whyUpgradeAnswer"
|
||||
defaults="When you upgrade to a paid plan, you will be able to finalize your polls and automatically send calendar invites to your participants with your selected date. We will also keep your polls indefinitely so they won't be automatically deleted even after they are finalized."
|
||||
></Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-x-8 gap-y-2 py-4 md:grid-cols-3">
|
||||
<h3 className="col-span-1">
|
||||
<Trans
|
||||
i18nKey="faq_howToUpgrade"
|
||||
defaults="How do I upgrade to a paid plan?"
|
||||
/>
|
||||
</h3>
|
||||
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
|
||||
<Trans
|
||||
i18nKey="faq_howToUpgradeAnswer"
|
||||
components={{
|
||||
a: (
|
||||
<Link
|
||||
className="text-link"
|
||||
href={linkToApp("/settings/billing")}
|
||||
/>
|
||||
),
|
||||
b: <strong />,
|
||||
}}
|
||||
defaults="To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-x-8 gap-y-2 py-4 md:grid-cols-3">
|
||||
<h3 className="col-span-1">
|
||||
<Trans
|
||||
i18nKey="faq_cancelSubscription"
|
||||
defaults="How do I cancel my subscription?"
|
||||
></Trans>
|
||||
</h3>
|
||||
<p className="col-span-2 text-sm leading-relaxed text-slate-600">
|
||||
<Trans
|
||||
i18nKey="faq_cancelSubscriptionAnswer"
|
||||
components={{
|
||||
a: <Link className="text-link" href="/settings/billing" />,
|
||||
b: <strong />,
|
||||
}}
|
||||
defaults="You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan."
|
||||
></Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getPageLayout;
|
||||
|
||||
export default Page;
|
||||
|
||||
export const getStaticProps = getStaticTranslations;
|
|
@ -11,7 +11,7 @@
|
|||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
html {
|
||||
@apply h-full font-sans text-base text-gray-700;
|
||||
@apply h-full font-sans text-base;
|
||||
}
|
||||
body #__next {
|
||||
@apply min-h-screen;
|
||||
|
@ -21,7 +21,7 @@
|
|||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
@apply font-sans font-semibold text-gray-800;
|
||||
@apply font-sans font-semibold;
|
||||
}
|
||||
h1 {
|
||||
@apply text-2xl;
|
||||
|
|
|
@ -165,12 +165,41 @@
|
|||
"editDetailsDescription": "Change the details of your event.",
|
||||
"editOptionsDescription": "Change the options available in your poll.",
|
||||
"finalizeDescription": "Select a final date for your event.",
|
||||
"plan": "Plan",
|
||||
"planDescription": "Choose your plan",
|
||||
"notificationsGuestTooltip": "Create an account or login to turn get notifications",
|
||||
"planFree": "Free",
|
||||
"dateAndTimeDescription": "Change your preferred date and time settings",
|
||||
"createdTime": "Created {relativeTime}",
|
||||
"permissionDeniedParticipant": "If you are not the poll creator, you should go to the Invite Page.",
|
||||
"goToInvite": "Go to Invite Page"
|
||||
"goToInvite": "Go to Invite Page",
|
||||
"planPro": "Pro",
|
||||
"Billing": "Billing",
|
||||
"annualBilling": "Annual billing (Save {discount}%)",
|
||||
"planUpgrade": "Upgrade",
|
||||
"subscriptionUpdatePayment": "Update Payment Details",
|
||||
"subscriptionCancel": "Cancel Subscription",
|
||||
"billingStatus": "Billing Status",
|
||||
"billingStatusDescription": "Manage your subscription and billing details",
|
||||
"subscriptionPlans": "Plans",
|
||||
"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",
|
||||
"billingStatusDeleted": "Cancelled",
|
||||
"endDate": "End date",
|
||||
"dueDate": "Next payment due",
|
||||
"billingStatusPlan": "Plan",
|
||||
"billingPeriod": "Period",
|
||||
"billingPeriodMonthly": "Monthly",
|
||||
"billingPeriodYearly": "Yearly",
|
||||
"plan_unlimitedPolls": "Unlimited polls",
|
||||
"plan_unlimitedParticipants": "Unlimited participants",
|
||||
"monthlyBillingDescription": "per month",
|
||||
"plan_finalizePolls": "Finalize polls",
|
||||
"plan_extendedPollLife": "Keep polls indefinitely",
|
||||
"plan_prioritySupport": "Priority support"
|
||||
}
|
||||
|
|
197
apps/web/src/components/billing/billing-plans.tsx
Normal file
197
apps/web/src/components/billing/billing-plans.tsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
import { CheckIcon } from "@rallly/icons";
|
||||
import {
|
||||
BillingPlan,
|
||||
BillingPlanFooter,
|
||||
BillingPlanHeader,
|
||||
BillingPlanPeriod,
|
||||
BillingPlanPerk,
|
||||
BillingPlanPerks,
|
||||
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 { 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";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-4">
|
||||
<Trans i18nKey="subscriptionPlans" defaults="Plans" />
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
<Trans
|
||||
i18nKey="subscriptionDescription"
|
||||
defaults="By subscribing, you not only gain access to all features but you are also directly supporting further development of Rallly."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id="annual-switch"
|
||||
checked={isBilledAnnually}
|
||||
onCheckedChange={(checked) => {
|
||||
setBilledAnnually(checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="annual-switch">
|
||||
<Trans
|
||||
i18nKey="annualBilling"
|
||||
defaults="Annual billing (Save {discount}%)"
|
||||
values={{
|
||||
discount: Math.round(100 - (annualPriceUsd / 12 / 5) * 100),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<BillingPlan>
|
||||
<BillingPlanHeader>
|
||||
<BillingPlanTitle>
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
</BillingPlanTitle>
|
||||
<BillingPlanPrice>$0</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans i18nKey="freeForever" defaults="free forever" />
|
||||
</BillingPlanPeriod>
|
||||
</BillingPlanHeader>
|
||||
<BillingPlanPerks>
|
||||
<BillingPlanPerk>
|
||||
<Trans i18nKey="plan_unlimitedPolls" defaults="Unlimited polls" />
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
</BillingPlanPerks>
|
||||
</BillingPlan>
|
||||
|
||||
<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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="planUpgrade" defaults="Upgrade" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ProPlan>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Perk = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<li className="flex">
|
||||
<CheckIcon className="mr-2 inline h-4 w-4 translate-y-0.5 -translate-x-0.5 text-green-600" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProPlan = ({
|
||||
annual,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
annual?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<BillingPlan variant="primary">
|
||||
<BillingPlanHeader>
|
||||
<BillingPlanTitle className="text-primary">
|
||||
<Trans i18nKey="planPro" defaults="Pro" />
|
||||
</BillingPlanTitle>
|
||||
{annual ? (
|
||||
<>
|
||||
<BillingPlanPrice discount={`$${(annualPriceUsd / 12).toFixed(2)}`}>
|
||||
${monthlyPriceUsd}
|
||||
</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans
|
||||
i18nKey="annualBillingDescription"
|
||||
defaults="per month, billed annually"
|
||||
/>
|
||||
</BillingPlanPeriod>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BillingPlanPrice>${monthlyPriceUsd}</BillingPlanPrice>
|
||||
<BillingPlanPeriod>
|
||||
<Trans i18nKey="monthlyBillingDescription" defaults="per month" />
|
||||
</BillingPlanPeriod>
|
||||
</>
|
||||
)}
|
||||
</BillingPlanHeader>
|
||||
<BillingPlanPerks>
|
||||
<BillingPlanPerk>
|
||||
<Trans i18nKey="plan_unlimitedPolls" defaults="Unlimited polls" />
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_unlimitedParticipants"
|
||||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<Perk>
|
||||
<Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" />
|
||||
</Perk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_extendedPollLife"
|
||||
defaults="Extended poll life"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans i18nKey="plan_prioritySupport" defaults="Priority support" />
|
||||
</BillingPlanPerk>
|
||||
</BillingPlanPerks>
|
||||
<BillingPlanFooter>{children}</BillingPlanFooter>
|
||||
</BillingPlan>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,11 @@
|
|||
import { Settings2Icon, UserIcon } from "@rallly/icons";
|
||||
import { CreditCardIcon, Settings2Icon, UserIcon } from "@rallly/icons";
|
||||
import { Card } from "@rallly/ui/card";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { Card } from "@/components/card";
|
||||
import { Container } from "@/components/container";
|
||||
import { StandardLayout } from "@/components/layouts/standard-layout";
|
||||
|
||||
|
@ -37,8 +37,8 @@ const MenuItem = (props: {
|
|||
export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<div>
|
||||
<Container className="px-0 sm:py-8">
|
||||
<Card className="mx-auto max-w-4xl" fullWidthOnMobile={true}>
|
||||
<Container className="p-2 sm:py-8">
|
||||
<Card className="mx-auto max-w-4xl overflow-hidden">
|
||||
<div className="flex gap-4 gap-x-6 border-b bg-gray-50 px-3 py-4 md:px-4">
|
||||
<IfAuthenticated>
|
||||
<MenuItem href="/settings/profile" icon={UserIcon}>
|
||||
|
@ -48,11 +48,11 @@ export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
|
|||
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
|
||||
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||
</MenuItem>
|
||||
{/* <IfAuthenticated>
|
||||
<IfAuthenticated>
|
||||
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
|
||||
<Trans i18nKey="billing" defaults="Billing" />
|
||||
</MenuItem>
|
||||
</IfAuthenticated> */}
|
||||
</IfAuthenticated>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
|
|
|
@ -23,6 +23,7 @@ export const ParticipantsProvider: React.FunctionComponent<{
|
|||
},
|
||||
{
|
||||
staleTime: 1000 * 10,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@ export const FinalizePollForm = ({
|
|||
onSubmit,
|
||||
}: {
|
||||
name: string;
|
||||
onSubmit: (data: FinalizeFormData) => void;
|
||||
onSubmit?: (data: FinalizeFormData) => void;
|
||||
}) => {
|
||||
const poll = usePoll();
|
||||
const [max, setMax] = React.useState(pageSize);
|
||||
|
@ -119,7 +119,7 @@ export const FinalizePollForm = ({
|
|||
<form
|
||||
id={name}
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit((data) => onSubmit?.(data))}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
@ -4,7 +4,7 @@ export const SettingsSection = (props: {
|
|||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-8 p-3 sm:p-6 md:grid-cols-3">
|
||||
<div className="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-4 p-3 sm:p-6 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{props.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
import {
|
||||
ChevronDown,
|
||||
CreditCardIcon,
|
||||
LifeBuoyIcon,
|
||||
ListIcon,
|
||||
LogInIcon,
|
||||
|
@ -10,6 +12,7 @@ import {
|
|||
UserIcon,
|
||||
UserPlusIcon,
|
||||
} from "@rallly/icons";
|
||||
import { Badge } from "@rallly/ui/badge";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
@ -26,6 +29,27 @@ import { CurrentUserAvatar } from "@/components/user";
|
|||
|
||||
import { IfAuthenticated, IfGuest, useUser } from "./user-provider";
|
||||
|
||||
const Plan = () => {
|
||||
const { isFetched, data } = trpc.user.getBilling.useQuery();
|
||||
if (!isFetched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPlus = data && data.endDate.getTime() > Date.now();
|
||||
|
||||
if (isPlus) {
|
||||
return (
|
||||
<Badge>
|
||||
<Trans i18nKey="planPro" defaults="Pro" />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
export const UserDropdown = () => {
|
||||
const { user } = useUser();
|
||||
return (
|
||||
|
@ -45,9 +69,7 @@ export const UserDropdown = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<span className="bg-foreground rounded-full px-1.5 py-0.5 text-xs text-white">
|
||||
<Trans i18nKey="planFree" defaults="Free" />
|
||||
</span>
|
||||
<Plan />
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
@ -71,12 +93,17 @@ export const UserDropdown = () => {
|
|||
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* <DropdownMenuItem asChild={true}>
|
||||
<Link href="/settings/billing" className="flex items-center gap-x-2">
|
||||
<IfAuthenticated>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="flex items-center gap-x-2"
|
||||
>
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
<Trans i18nKey="Billing" defaults="Billing" />
|
||||
</Link>
|
||||
</DropdownMenuItem> */}
|
||||
</DropdownMenuItem>
|
||||
</IfAuthenticated>
|
||||
<DropdownMenuItem asChild={true}>
|
||||
<Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
|
||||
<ListIcon className="h-4 w-4" />
|
||||
|
|
|
@ -49,6 +49,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
|||
const queryClient = trpc.useContext();
|
||||
|
||||
const { data: user } = trpc.whoami.get.useQuery();
|
||||
const billingQuery = trpc.user.getBilling.useQuery();
|
||||
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
|
||||
|
||||
const shortName = user
|
||||
|
@ -57,7 +58,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
|||
: user.id.substring(0, 10)
|
||||
: t("guest");
|
||||
|
||||
if (!user || userPreferences === undefined) {
|
||||
if (!user || userPreferences === undefined || !billingQuery.isFetched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
11
apps/web/src/contexts/plan.tsx
Normal file
11
apps/web/src/contexts/plan.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
|
||||
export const usePlan = () => {
|
||||
const { data } = trpc.user.getBilling.useQuery(undefined, {
|
||||
staleTime: 10 * 1000,
|
||||
});
|
||||
|
||||
const isPaid = Boolean(data && data.endDate.getTime() > Date.now());
|
||||
|
||||
return isPaid ? "paid" : "free";
|
||||
};
|
|
@ -5,14 +5,16 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
|
||||
const supportedLocales = Object.keys(languages);
|
||||
|
||||
// these paths are always public
|
||||
const publicPaths = ["/login", "/register", "/invite", "/auth"];
|
||||
// these paths always require authentication
|
||||
const protectedPaths = ["/settings/billing", "/settings/profile"];
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { headers, cookies, nextUrl } = req;
|
||||
const newUrl = nextUrl.clone();
|
||||
const res = NextResponse.next();
|
||||
const session = await getSession(req, res);
|
||||
|
||||
if (
|
||||
process.env.AUTH_REQUIRED &&
|
||||
session.user?.isGuest !== false &&
|
||||
|
@ -24,6 +26,16 @@ export async function middleware(req: NextRequest) {
|
|||
return NextResponse.redirect(newUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.isGuest !== false &&
|
||||
protectedPaths.some((protectedPath) =>
|
||||
req.nextUrl.pathname.includes(protectedPath),
|
||||
)
|
||||
) {
|
||||
newUrl.pathname = "/login";
|
||||
return NextResponse.redirect(newUrl);
|
||||
}
|
||||
|
||||
// Check if locale is specified in cookie
|
||||
const localeCookie = cookies.get("NEXT_LOCALE");
|
||||
const preferredLocale = localeCookie && localeCookie.value;
|
||||
|
|
99
apps/web/src/paddle.interface.ts
Normal file
99
apps/web/src/paddle.interface.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Original source: https://gist.github.com/dsumer/5a4b120d6c8bde061b75667b067797c7
|
||||
|
||||
export interface PaddlePassthrough {
|
||||
userId: string; // the id of the user in our supabase database
|
||||
}
|
||||
|
||||
export type PaddleSubscriptionStatus =
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "past_due"
|
||||
| "paused"
|
||||
| "deleted";
|
||||
|
||||
type AlertName =
|
||||
| "subscription_created"
|
||||
| "subscription_updated"
|
||||
| "subscription_cancelled"
|
||||
| "subscription_payment_succeeded"
|
||||
| "subscription_payment_failed"
|
||||
| "subscription_payment_refunded";
|
||||
|
||||
export type PaymentStatus = "success" | "error" | "refund";
|
||||
|
||||
interface BasePaddleRequest {
|
||||
alert_id: string;
|
||||
alert_name: AlertName;
|
||||
status: PaddleSubscriptionStatus;
|
||||
/**
|
||||
* Holds the data we pass to Paddle at the checkout as a JSON string.
|
||||
* Take a look at {@link PaddlePassthrough} to see what it contains.
|
||||
*/
|
||||
passthrough: string;
|
||||
subscription_id: string;
|
||||
subscription_plan_id: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface SubscriptionCreatedRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_created";
|
||||
next_bill_date: string;
|
||||
cancel_url: string;
|
||||
update_url: string;
|
||||
unit_price: string;
|
||||
}
|
||||
|
||||
interface SubscriptionUpdatedRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_updated";
|
||||
next_bill_date: string;
|
||||
cancel_url: string;
|
||||
update_url: string;
|
||||
new_unit_price: string;
|
||||
}
|
||||
|
||||
interface SubscriptionCancelledRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_cancelled";
|
||||
cancellation_effective_date: string;
|
||||
}
|
||||
|
||||
interface SubscriptionPaymentSucceededRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_payment_succeeded";
|
||||
subscription_payment_id: string;
|
||||
country: string;
|
||||
currency: string;
|
||||
customer_name: string;
|
||||
fee: string;
|
||||
payment_method: string;
|
||||
payment_tax: string;
|
||||
receipt_url: string;
|
||||
sale_gross: string;
|
||||
next_bill_date: string;
|
||||
}
|
||||
|
||||
interface SubscriptionPaymentFailedRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_payment_failed";
|
||||
subscription_payment_id: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
next_retry_date?: string;
|
||||
attempt_number: string;
|
||||
}
|
||||
|
||||
interface SubscriptionPaymentRefundedRequest extends BasePaddleRequest {
|
||||
alert_name: "subscription_payment_refunded";
|
||||
subscription_payment_id: string;
|
||||
gross_refund: string;
|
||||
fee_refund: string;
|
||||
tax_refund: string;
|
||||
currency: string;
|
||||
refund_reason: string;
|
||||
refund_type: string;
|
||||
}
|
||||
|
||||
export type PaddleRequest =
|
||||
| SubscriptionCreatedRequest
|
||||
| SubscriptionUpdatedRequest
|
||||
| SubscriptionCancelledRequest
|
||||
| SubscriptionPaymentSucceededRequest
|
||||
| SubscriptionPaymentFailedRequest
|
||||
| SubscriptionPaymentRefundedRequest;
|
204
apps/web/src/pages/api/paddle.ts
Normal file
204
apps/web/src/pages/api/paddle.ts
Normal file
|
@ -0,0 +1,204 @@
|
|||
// Original source: https://gist.github.com/dsumer/3594cda57e84a93a9019cddc71831882
|
||||
import { prisma } from "@rallly/database";
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import * as Serialize from "php-serialize";
|
||||
|
||||
import { PaddlePassthrough, PaddleRequest } from "@/paddle.interface";
|
||||
|
||||
const allowedIpAdresses = [
|
||||
// Sandbox
|
||||
"34.194.127.46",
|
||||
"54.234.237.108",
|
||||
"3.208.120.145",
|
||||
"44.226.236.210",
|
||||
"44.241.183.62",
|
||||
"100.20.172.113",
|
||||
// Production
|
||||
"34.232.58.13",
|
||||
"34.195.105.136",
|
||||
"34.237.3.244",
|
||||
"35.155.119.135",
|
||||
"52.11.166.252",
|
||||
"34.212.5.7",
|
||||
];
|
||||
|
||||
const getIpAddress = (req: NextApiRequest): string => {
|
||||
const forwarded = req.headers["x-forwarded-for"] || "";
|
||||
if (typeof forwarded === "string") {
|
||||
return forwarded.split(",")[0] || req.socket.remoteAddress || "";
|
||||
}
|
||||
return forwarded[0] || req.socket.remoteAddress || "";
|
||||
};
|
||||
|
||||
function ksort(obj: Record<string, unknown>) {
|
||||
const keys = Object.keys(obj).sort();
|
||||
const sortedObj: Record<string, unknown> = {};
|
||||
for (const i in keys) {
|
||||
sortedObj[keys[i]] = obj[keys[i]];
|
||||
}
|
||||
return sortedObj;
|
||||
}
|
||||
|
||||
export function validateWebhook(req: NextApiRequest) {
|
||||
if (!allowedIpAdresses.includes(getIpAddress(req))) {
|
||||
console.error("No valid paddle ip address");
|
||||
return false;
|
||||
}
|
||||
|
||||
let jsonObj = req.body;
|
||||
// Grab p_signature
|
||||
const mySig = Buffer.from(jsonObj.p_signature, "base64");
|
||||
// Remove p_signature from object - not included in array of fields used in verification.
|
||||
delete jsonObj.p_signature;
|
||||
// Need to sort array by key in ascending order
|
||||
jsonObj = ksort(jsonObj);
|
||||
for (const property in jsonObj) {
|
||||
if (
|
||||
jsonObj.hasOwnProperty(property) &&
|
||||
typeof jsonObj[property] !== "string"
|
||||
) {
|
||||
if (Array.isArray(jsonObj[property])) {
|
||||
// is it an array
|
||||
jsonObj[property] = jsonObj[property].toString();
|
||||
} else {
|
||||
//if its not an array and not a string, then it is a JSON obj
|
||||
jsonObj[property] = JSON.stringify(jsonObj[property]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialise remaining fields of jsonObj
|
||||
const serialized = Serialize.serialize(jsonObj);
|
||||
// verify the serialized array against the signature using SHA1 with your public key.
|
||||
const verifier = crypto.createVerify("sha1");
|
||||
verifier.update(serialized);
|
||||
verifier.end();
|
||||
|
||||
if (!process.env.PADDLE_PUBLIC_KEY) {
|
||||
throw new Error("Missing paddle public key");
|
||||
}
|
||||
|
||||
const publicKey = crypto.createPublicKey(
|
||||
`-----BEGIN PUBLIC KEY-----\n${process.env.PADDLE_PUBLIC_KEY}\n-----END PUBLIC KEY-----`,
|
||||
);
|
||||
|
||||
const isValid = verifier.verify(publicKey, mySig);
|
||||
|
||||
if (!isValid) {
|
||||
console.error("Invalid paddle signature");
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "POST") {
|
||||
// Your Paddle webhook code will be handled here
|
||||
|
||||
// The webhook payload is sent as a form-data
|
||||
const payload: PaddleRequest = req.body;
|
||||
|
||||
const isValid = validateWebhook(req);
|
||||
|
||||
if (!isValid) {
|
||||
// The signature is not valid, response with an error
|
||||
return res.status(500).json({ error: "Invalid signature" });
|
||||
}
|
||||
|
||||
let passthrough: PaddlePassthrough | null = null;
|
||||
try {
|
||||
passthrough = JSON.parse(payload.passthrough) as PaddlePassthrough;
|
||||
} catch {}
|
||||
if (!passthrough) {
|
||||
res.status(400).send("Invalid passthrough: " + payload.passthrough);
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point, the webhook is valid, handle the webhook event
|
||||
switch (payload.alert_name) {
|
||||
case "subscription_created": {
|
||||
// Handle new subscription
|
||||
const data = {
|
||||
subscriptionId: payload.subscription_id,
|
||||
status: payload.status,
|
||||
planId: payload.subscription_plan_id,
|
||||
endDate: new Date(payload.next_bill_date),
|
||||
updateUrl: payload.update_url,
|
||||
cancelUrl: payload.cancel_url,
|
||||
};
|
||||
|
||||
await prisma.userPaymentData.upsert({
|
||||
where: {
|
||||
userId: passthrough.userId,
|
||||
},
|
||||
create: {
|
||||
userId: passthrough.userId,
|
||||
...data,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
break;
|
||||
}
|
||||
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),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "subscription_payment_failed":
|
||||
await prisma.userPaymentData.update({
|
||||
where: {
|
||||
userId: passthrough.userId,
|
||||
},
|
||||
data: {
|
||||
status: payload.status,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "subscription_updated":
|
||||
// Handle updated subscription
|
||||
await prisma.userPaymentData.update({
|
||||
where: {
|
||||
userId: passthrough.userId,
|
||||
},
|
||||
data: {
|
||||
status: payload.status,
|
||||
planId: payload.subscription_plan_id,
|
||||
endDate: new Date(payload.next_bill_date),
|
||||
updateUrl: payload.update_url,
|
||||
cancelUrl: payload.cancel_url,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "subscription_cancelled":
|
||||
// Handle cancelled subscription
|
||||
await prisma.userPaymentData.update({
|
||||
where: {
|
||||
userId: passthrough.userId,
|
||||
},
|
||||
data: {
|
||||
status: payload.status,
|
||||
endDate: new Date(payload.cancellation_effective_date),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// If the webhook event is not handled, respond with an error
|
||||
return res.status(400).json({ error: "Webhook event not supported" });
|
||||
}
|
||||
|
||||
// If everything went well, send a 200 OK
|
||||
return res.status(200).json({ success: true });
|
||||
} else {
|
||||
}
|
||||
}
|
|
@ -1,11 +1,5 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
import { ArrowRightIcon } from "@rallly/icons";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@rallly/ui/accordion";
|
||||
import { LockIcon } from "@rallly/icons";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
CardContent,
|
||||
|
@ -16,18 +10,20 @@ import {
|
|||
} from "@rallly/ui/card";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
|
||||
import { ProPlan } from "@/components/billing/billing-plans";
|
||||
import { Card } from "@/components/card";
|
||||
import { getPollLayout } from "@/components/layouts/poll-layout";
|
||||
import { FinalizePollForm } from "@/components/poll/manage-poll/finalize-poll-dialog";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { usePlan } from "@/contexts/plan";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { NextPageWithLayout } from "@/types";
|
||||
import { getStaticTranslations } from "@/utils/with-page-translations";
|
||||
|
||||
const FinalizationForm = () => {
|
||||
const { poll } = usePoll();
|
||||
const plan = usePlan();
|
||||
const poll = usePoll();
|
||||
const router = useRouter();
|
||||
const redirectBackToPoll = () => {
|
||||
router.replace(`/poll/${poll.id}`);
|
||||
|
@ -58,7 +54,7 @@ const FinalizationForm = () => {
|
|||
<FinalizePollForm
|
||||
name="finalize"
|
||||
onSubmit={(data) => {
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_FINALIZATION === "true") {
|
||||
if (plan === "paid") {
|
||||
bookDate.mutateAsync({
|
||||
pollId: poll.id,
|
||||
optionId: data.selectedOptionId,
|
||||
|
@ -87,86 +83,46 @@ const FinalizationForm = () => {
|
|||
|
||||
const Teaser = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<div className="bg-pattern overflow-hidden rounded-md border p-8 shadow-sm">
|
||||
<div className="mb-4 text-center">
|
||||
<div className="mb-4 flex items-center justify-center gap-4">
|
||||
<span className="bg-primary rounded-full px-2.5 py-0.5 text-sm font-semibold text-white">
|
||||
Pro
|
||||
</span>
|
||||
<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>
|
||||
<h1 className="mb-1">{`Finalize your Poll`}</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-lg">
|
||||
{`Subscribe to get notified when it's ready.`}
|
||||
<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>
|
||||
<Script
|
||||
id="mailerlite"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(w,d,e,u,f,l,n){w[f]=w[f]||function(){(w[f].q=w[f].q||[])
|
||||
.push(arguments);},l=d.createElement(e),l.async=1,l.src=u,
|
||||
n=d.getElementsByTagName(e)[0],n.parentNode.insertBefore(l,n);})
|
||||
(window,document,'script','https://assets.mailerlite.com/js/universal.js','ml');
|
||||
ml('account', '99567');`,
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<ProPlan annual={true}>
|
||||
<Button variant="primary" asChild className="w-full">
|
||||
<Link href="/settings/billing">
|
||||
<Trans
|
||||
i18nKey="upgradeOverlayGoToBilling"
|
||||
defaults="Go to billing"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="ml-embedded mx-auto max-w-sm p-0"
|
||||
data-form="h9YecB"
|
||||
/>
|
||||
<div className="mb-8 flex justify-center gap-x-2">
|
||||
<Link
|
||||
className="text-primary inline-flex items-center gap-x-2 text-sm"
|
||||
href={`${process.env.NEXT_PUBLIC_LANDING_PAGE_URL}/blog/introducing-rallly-pro`}
|
||||
>
|
||||
Learn about Rallly Pro <ArrowRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</ProPlan>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer pointer-events-none mx-auto -mb-24 max-w-3xl select-none">
|
||||
<FinalizationForm />
|
||||
</div>
|
||||
</div>
|
||||
<Accordion type="single">
|
||||
<AccordionItem value="whyNotFree">
|
||||
<AccordionTrigger>{`Why isn't this free?`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{`Rallly has been running for over 7 years and has been free for everyone to use. We've been able to do this thanks to the generosity of our users who have donated to help keep the service running. However, we've reached a point where we need to start generating revenue to be able to grow and continue to develop new features. We've decided to introduce a paid tier to help us achieve this.`}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="whatAreTheBenefits">
|
||||
<AccordionTrigger>
|
||||
{`What are the benefits of upgrading to Rallly Pro?`}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{`As a Pro user, you will be able to finalize your polls and send calendar invites to your participants. We plan to deliver plenty of new features and as a Pro user you will have access to all new features as well. You will also receive priority support and be able to help shape the future of Rallly.`}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="howMuch">
|
||||
<AccordionTrigger>How much does Rallly Pro cost?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{`For early adopters, Rallly Pro will be available for just $4.99 per month or $24.99 if you sign up for a whole year. We will adjust the price as we add more features so you will be getting a significantly reduced rate by signing up early.`}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="freeUse">
|
||||
<AccordionTrigger>Can I still use Rallly for free?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{`Yes, the free service remains exactly the same.`}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="howToUpgrade">
|
||||
<AccordionTrigger>When can I upgrade to Rallly Pro?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{`We're just setting up a checkout system and once this is ready you will be able to upgrade.`}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_FINALIZATION === "true") {
|
||||
const plan = usePlan();
|
||||
|
||||
if (plan === "paid") {
|
||||
return <FinalizationForm />;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
import { CreditCardIcon } from "@rallly/icons";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Card } from "@rallly/ui/card";
|
||||
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";
|
||||
import { getProfileLayout } from "@/components/layouts/profile-layout";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { usePlan } from "@/contexts/plan";
|
||||
|
||||
import { NextPageWithLayout } from "../../types";
|
||||
import { getStaticTranslations } from "../../utils/with-page-translations";
|
||||
|
@ -17,6 +26,147 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export const proPlanIdMonthly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
|
||||
|
||||
export const proPlanIdYearly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
|
||||
|
||||
const SubscriptionStatus = () => {
|
||||
const { user } = useUser();
|
||||
|
||||
trpc.user.getBilling.useQuery(undefined, {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const plan = usePlan();
|
||||
const isPlus = plan === "paid";
|
||||
|
||||
if (user.isGuest) {
|
||||
return <>You need to be logged in.</>;
|
||||
}
|
||||
|
||||
if (isPlus) {
|
||||
return <BillingStatus />;
|
||||
} else {
|
||||
return <BillingPlans />;
|
||||
}
|
||||
};
|
||||
|
||||
const BillingStatus = () => {
|
||||
const { data: userPaymentData } = trpc.user.getBilling.useQuery();
|
||||
|
||||
if (!userPaymentData) {
|
||||
return <p>Something when wrong. Missing user payment data.</p>;
|
||||
}
|
||||
|
||||
const { status, endDate, planId } = userPaymentData;
|
||||
|
||||
if (status === "trialing" || status === "past_due") {
|
||||
return <p>Invalid billing status</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="grid gap-4 p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>
|
||||
<Trans i18nKey="billingStatusState" defaults="Status" />
|
||||
</Label>
|
||||
<div>
|
||||
{(() => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return (
|
||||
<Trans i18nKey="billingStatusActive" defaults="Active" />
|
||||
);
|
||||
case "paused":
|
||||
return (
|
||||
<Trans i18nKey="billingStatusPaused" defaults="Paused" />
|
||||
);
|
||||
case "deleted":
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="billingStatusDeleted"
|
||||
defaults="Cancelled"
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{status === "deleted" ? (
|
||||
<Label>
|
||||
<Trans i18nKey="endDate" defaults="End date" />
|
||||
</Label>
|
||||
) : (
|
||||
<Label>
|
||||
<Trans i18nKey="dueDate" defaults="Due date" />
|
||||
</Label>
|
||||
)}
|
||||
<div>{dayjs(endDate).format("LL")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
<Trans i18nKey="billingStatusPlan" defaults="Plan" />
|
||||
</Label>
|
||||
<div>
|
||||
<Trans i18nKey="planPro" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
<Trans i18nKey="billingPeriod" defaults="Period" />
|
||||
</Label>
|
||||
<div>
|
||||
{planId === proPlanIdMonthly ? (
|
||||
<Trans i18nKey="billingPeriodMonthly" defaults="Monthly" />
|
||||
) : (
|
||||
<Trans i18nKey="billingPeriodYearly" defaults="Yearly" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status === "active" || status === "paused" ? (
|
||||
<div className="flex items-center gap-x-2 border-t bg-gray-50 p-3">
|
||||
<Button
|
||||
asChild
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.Paddle.Checkout.open({
|
||||
override: userPaymentData.updateUrl,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={userPaymentData.updateUrl}>
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
<Trans
|
||||
i18nKey="subscriptionUpdatePayment"
|
||||
defaults="Update Payment Details"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.Paddle.Checkout.open({
|
||||
override: userPaymentData.cancelUrl,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link href={userPaymentData.cancelUrl}>
|
||||
<Trans i18nKey="subscriptionCancel" defaults="Cancel" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
@ -28,26 +178,24 @@ const Page: NextPageWithLayout = () => {
|
|||
<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: 12731 });
|
||||
}
|
||||
window.Paddle.Setup({
|
||||
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SettingsSection
|
||||
title={<Trans i18nKey="plan" defaults="Plan" />}
|
||||
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
|
||||
description={
|
||||
<Trans i18nKey="planDescription" defaults="Choose your plan" />
|
||||
<Trans
|
||||
i18nKey="billingStatusDescription"
|
||||
defaults="Manage your subscription and billing details."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.Paddle.Checkout.open({
|
||||
allowQuantity: false,
|
||||
product: 53102,
|
||||
})
|
||||
}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
<SubscriptionStatus />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,18 +1,5 @@
|
|||
import { Placement } from "@floating-ui/react-dom-interactions";
|
||||
export const plusPlanIdMonthly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
|
||||
|
||||
export const isInMaintenanceMode = process.env.MAINTENANCE_MODE === "true";
|
||||
|
||||
export const transformOriginByPlacement: Record<Placement, string> = {
|
||||
bottom: "origin-top",
|
||||
"bottom-end": "origin-top-right",
|
||||
"bottom-start": "origin-top-left",
|
||||
left: "origin-right",
|
||||
"left-start": "origin-top-right",
|
||||
"left-end": "origin-bottom-right",
|
||||
right: "origin-left",
|
||||
"right-start": "origin-top-left",
|
||||
"right-end": "origin-bottom-left",
|
||||
top: "origin-bottom",
|
||||
"top-start": "origin-bottom-left",
|
||||
"top-end": "origin-bottom-right",
|
||||
};
|
||||
export const plusPlanIdYearly = process.env
|
||||
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
|
||||
|
|
|
@ -9,7 +9,7 @@ import utc from "dayjs/plugin/utc";
|
|||
import * as ics from "ics";
|
||||
import { z } from "zod";
|
||||
|
||||
import { printDate } from "../../utils/date";
|
||||
import { getTimeZoneAbbreviation } from "../../utils/date";
|
||||
import { nanoid } from "../../utils/nanoid";
|
||||
import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
|
||||
import { comments } from "./polls/comments";
|
||||
|
@ -496,12 +496,6 @@ export const polls = router({
|
|||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_FINALIZATION !== "true") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This feature is not enabled",
|
||||
});
|
||||
}
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: {
|
||||
id: input.pollId,
|
||||
|
@ -511,6 +505,7 @@ export const polls = router({
|
|||
timeZone: true,
|
||||
title: true,
|
||||
location: true,
|
||||
description: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
|
@ -564,15 +559,17 @@ export const polls = router({
|
|||
});
|
||||
}
|
||||
|
||||
const eventStart = poll.timeZone
|
||||
? dayjs(option.start).utc().tz(poll.timeZone, true).toDate()
|
||||
: option.start;
|
||||
let eventStart = dayjs(option.start).utc();
|
||||
|
||||
if (poll.timeZone) {
|
||||
eventStart = eventStart.tz(poll.timeZone, true);
|
||||
}
|
||||
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
pollId: poll.id,
|
||||
optionId: input.optionId,
|
||||
start: eventStart,
|
||||
start: eventStart.toDate(),
|
||||
duration: option.duration,
|
||||
title: poll.title,
|
||||
userId: ctx.user.id,
|
||||
|
@ -583,46 +580,49 @@ export const polls = router({
|
|||
p.votes.some((v) => v.optionId === input.optionId && v.type !== "no"),
|
||||
);
|
||||
|
||||
let event: ics.ReturnObject;
|
||||
if (option.duration > 0) {
|
||||
// we need to remember to call .utc() on the dayjs() object
|
||||
// to make sure we get the correct time because dayjs() will
|
||||
// use the local timezone
|
||||
const start = poll.timeZone
|
||||
? dayjs(option.start).utc().tz(poll.timeZone, true).utc()
|
||||
: dayjs(option.start).utc();
|
||||
|
||||
event = ics.createEvent({
|
||||
title: poll.title,
|
||||
start: [
|
||||
start.year(),
|
||||
start.month() + 1,
|
||||
start.date(),
|
||||
start.hour(),
|
||||
start.minute(),
|
||||
],
|
||||
organizer: {
|
||||
name: poll.user.name,
|
||||
email: poll.user.email,
|
||||
},
|
||||
startInputType: poll.timeZone ? "utc" : "local",
|
||||
duration: { minutes: option.duration },
|
||||
attendees: attendees
|
||||
const icsAttendees = attendees
|
||||
.filter((a) => !!a.email) // remove participants without email
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
email: a.email ?? undefined,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
const start = dayjs(option.start);
|
||||
const end = start.add(1, "day");
|
||||
event = ics.createEvent({
|
||||
}));
|
||||
|
||||
const utcStart = eventStart.utc();
|
||||
const eventEnd =
|
||||
option.duration > 0
|
||||
? eventStart.add(option.duration, "minutes")
|
||||
: eventStart.add(1, "day");
|
||||
|
||||
const event = ics.createEvent({
|
||||
title: poll.title,
|
||||
start: [start.year(), start.month() + 1, start.date()],
|
||||
end: [end.year(), end.month() + 1, end.date()],
|
||||
});
|
||||
location: poll.location ?? undefined,
|
||||
description: poll.description ?? undefined,
|
||||
organizer: {
|
||||
name: poll.user.name,
|
||||
email: poll.user.email,
|
||||
},
|
||||
attendees: icsAttendees,
|
||||
...(option.duration > 0
|
||||
? {
|
||||
start: [
|
||||
utcStart.year(),
|
||||
utcStart.month() + 1,
|
||||
utcStart.date(),
|
||||
utcStart.hour(),
|
||||
utcStart.minute(),
|
||||
],
|
||||
startInputType: poll.timeZone ? "utc" : "local",
|
||||
duration: { minutes: option.duration },
|
||||
}
|
||||
: {
|
||||
start: [
|
||||
eventStart.year(),
|
||||
eventStart.month() + 1,
|
||||
eventStart.date(),
|
||||
],
|
||||
end: [eventEnd.year(), eventEnd.month() + 1, eventEnd.date()],
|
||||
}),
|
||||
});
|
||||
|
||||
if (event.error) {
|
||||
throw new TRPCError({
|
||||
|
@ -637,30 +637,21 @@ export const polls = router({
|
|||
message: "Failed to generate ics",
|
||||
});
|
||||
} else {
|
||||
const formattedDate = printDate(
|
||||
eventStart,
|
||||
option.duration,
|
||||
poll.timeZone ?? undefined,
|
||||
const timeZone = poll.timeZone ?? "UTC";
|
||||
const timeZoneAbbrev = getTimeZoneAbbreviation(
|
||||
eventStart.toDate(),
|
||||
timeZone,
|
||||
);
|
||||
// const formatDate = (
|
||||
// date: Date,
|
||||
// duration: number,
|
||||
// timeZone?: string | null,
|
||||
// ) => {
|
||||
// if (duration > 0) {
|
||||
// if (timeZone) {
|
||||
// return `${dayjs(date)
|
||||
// .utc()
|
||||
// .format(
|
||||
// "dddd, MMMM D, YYYY, HH:mm",
|
||||
// )} (${getTimeZoneAbbreviation(timeZone)})`;
|
||||
// } else {
|
||||
// return dayjs(date).utc().format("dddd, MMMM D, YYYY, HH:mm");
|
||||
// }
|
||||
// } else {
|
||||
// return dayjs(date).format("dddd, MMMM D, YYYY");
|
||||
// }
|
||||
// };
|
||||
const date = eventStart.format("dddd, MMMM D, YYYY");
|
||||
const day = eventStart.format("D");
|
||||
const dow = eventStart.format("ddd");
|
||||
const startTime = eventStart.format("hh:mm A");
|
||||
const endTime = eventEnd.format("hh:mm A");
|
||||
|
||||
const time =
|
||||
option.duration > 0
|
||||
? `${startTime} - ${endTime} ${timeZoneAbbrev}`
|
||||
: "All-day";
|
||||
|
||||
const participantsToEmail: Array<{ name: string; email: string }> = [];
|
||||
|
||||
|
@ -701,13 +692,16 @@ export const polls = router({
|
|||
),
|
||||
)
|
||||
.map((p) => p.name),
|
||||
date: formattedDate,
|
||||
date,
|
||||
day,
|
||||
dow,
|
||||
time,
|
||||
},
|
||||
attachments: [{ filename: "event.ics", content: event.value }],
|
||||
});
|
||||
|
||||
const emailsToParticipants = participantsToEmail.map((p) => {
|
||||
return sendEmail("FinalizeHostEmail", {
|
||||
return sendEmail("FinalizeParticipantEmail", {
|
||||
subject: `Date booked for ${poll.title}`,
|
||||
to: p.email,
|
||||
props: {
|
||||
|
@ -715,6 +709,7 @@ export const polls = router({
|
|||
pollUrl: absoluteUrl(`/poll/${poll.id}`),
|
||||
location: poll.location,
|
||||
title: poll.title,
|
||||
hostName: poll.user?.name ?? "",
|
||||
attendees: poll.participants
|
||||
.filter((p) =>
|
||||
p.votes.some(
|
||||
|
@ -722,7 +717,10 @@ export const polls = router({
|
|||
),
|
||||
)
|
||||
.map((p) => p.name),
|
||||
date: formattedDate,
|
||||
date,
|
||||
day,
|
||||
dow,
|
||||
time,
|
||||
},
|
||||
attachments: [{ filename: "event.ics", content: event.value }],
|
||||
});
|
||||
|
|
|
@ -4,29 +4,20 @@ import { z } from "zod";
|
|||
import { publicProcedure, router } from "../trpc";
|
||||
|
||||
export const user = router({
|
||||
getPolls: publicProcedure.query(async ({ ctx }) => {
|
||||
const userPolls = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
getBilling: publicProcedure.query(async ({ ctx }) => {
|
||||
return await prisma.userPaymentData.findUnique({
|
||||
select: {
|
||||
polls: {
|
||||
subscriptionId: true,
|
||||
status: true,
|
||||
planId: true,
|
||||
endDate: true,
|
||||
updateUrl: true,
|
||||
cancelUrl: true,
|
||||
},
|
||||
where: {
|
||||
deleted: false,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
closed: true,
|
||||
createdAt: true,
|
||||
adminUrlId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
return userPolls;
|
||||
}),
|
||||
changeName: publicProcedure
|
||||
.input(
|
||||
|
|
|
@ -15,19 +15,3 @@ export const getTimeZoneAbbreviation = (date: Date, timeZone: string) => {
|
|||
const abbrev = spaceTimeDate.isDST() ? dstAbbrev : standardAbbrev;
|
||||
return abbrev;
|
||||
};
|
||||
|
||||
export const printDate = (date: Date, duration: number, timeZone?: string) => {
|
||||
if (duration === 0) {
|
||||
return dayjs(date).format("LL");
|
||||
} else if (timeZone) {
|
||||
return `${dayjs(date).tz(timeZone).format("LLL")} - ${dayjs(date)
|
||||
.add(duration, "minutes")
|
||||
.tz(timeZone)
|
||||
.format("LT")} ${getTimeZoneAbbreviation(date, timeZone)}`;
|
||||
} else {
|
||||
return `${dayjs(date).utc().format("LLL")} - ${dayjs(date)
|
||||
.utc()
|
||||
.add(duration, "minutes")
|
||||
.format("LT")}`;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "subscription_status" AS ENUM ('active', 'paused', 'deleted', 'trialing', 'past_due');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_payment_data" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"subscription_id" TEXT NOT NULL,
|
||||
"plan_id" TEXT NOT NULL,
|
||||
"end_date" TIMESTAMP(3) NOT NULL,
|
||||
"status" "subscription_status" NOT NULL,
|
||||
"update_url" TEXT NOT NULL,
|
||||
"cancel_url" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "user_payment_data_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
|
@ -30,15 +30,27 @@ model User {
|
|||
@@map("users")
|
||||
}
|
||||
|
||||
// model UserPaymentData {
|
||||
// userId String @id @map("user_id")
|
||||
// subscriptionId String @map("subscription_id")
|
||||
// subscriptionPlanId String @map("subscription_plan_id")
|
||||
// subscriptionEndDate DateTime @map("subscription_end_date")
|
||||
// subscriptionStatus String @map("subscription_status")
|
||||
// subscriptionUpdateUrl String @map("subscription_update_url")
|
||||
// subscriptionCancelUrl String @map("subscription_cancel_url")
|
||||
// }
|
||||
enum SubscriptionStatus {
|
||||
active
|
||||
paused
|
||||
deleted
|
||||
trialing
|
||||
past_due
|
||||
|
||||
@@map("subscription_status")
|
||||
}
|
||||
|
||||
model UserPaymentData {
|
||||
userId String @id @map("user_id")
|
||||
subscriptionId String @map("subscription_id")
|
||||
planId String @map("plan_id")
|
||||
endDate DateTime @map("end_date")
|
||||
status SubscriptionStatus
|
||||
updateUrl String @map("update_url")
|
||||
cancelUrl String @map("cancel_url")
|
||||
|
||||
@@map("user_payment_data")
|
||||
}
|
||||
|
||||
model UserPreferences {
|
||||
userId String @id @map("user_id")
|
||||
|
@ -98,7 +110,6 @@ model Event {
|
|||
@@map("events")
|
||||
}
|
||||
|
||||
|
||||
model Watcher {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./templates/finalized-host";
|
||||
export * from "./templates/finalized-participant";
|
||||
export * from "./templates/login";
|
||||
export * from "./templates/new-comment";
|
||||
export * from "./templates/new-participant";
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
|
||||
import { getDomain } from "./utils";
|
||||
|
||||
export const borderColor = "#E2E8F0";
|
||||
export const Text = (
|
||||
props: TextProps & { light?: boolean; small?: boolean },
|
||||
) => {
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { Column, Row, Section } from "@react-email/components";
|
||||
|
||||
import { EmailLayout } from "./components/email-layout";
|
||||
import { Button, Card, Text } from "./components/styled-components";
|
||||
import { borderColor, Button, Text } from "./components/styled-components";
|
||||
|
||||
export interface FinalizeHostEmailProps {
|
||||
date: string;
|
||||
day: string;
|
||||
dow: string;
|
||||
time: string;
|
||||
name: string;
|
||||
title: string;
|
||||
location: string | null;
|
||||
|
@ -14,17 +19,54 @@ export const FinalizeHostEmail = ({
|
|||
name = "Guest",
|
||||
title = "Untitled Poll",
|
||||
pollUrl = "https://rallly.co",
|
||||
date = "Friday, 12th June 2020 at 12:00pm",
|
||||
day = "12",
|
||||
dow = "Fri",
|
||||
date = "Friday, 12th June 2020",
|
||||
time = "6:00 PM to 11:00 PM BST",
|
||||
}: FinalizeHostEmailProps) => {
|
||||
return (
|
||||
<EmailLayout recipientName={name} preview="Final date booked!">
|
||||
<Text>
|
||||
Well done for finding a date for <strong>{title}</strong>!
|
||||
<strong>{title}</strong> has been booked for:
|
||||
</Text>
|
||||
<Text>Your date has been booked for:</Text>
|
||||
<Card>
|
||||
<Text style={{ fontWeight: "bold", textAlign: "center" }}>{date}</Text>
|
||||
</Card>
|
||||
<Section>
|
||||
<Row>
|
||||
<Column style={{ width: 48 }}>
|
||||
<Section
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
margin: 0,
|
||||
width: 48,
|
||||
height: 48,
|
||||
textAlign: "center",
|
||||
border: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ margin: "0 0 4px 0", fontSize: 10, lineHeight: 1 }}
|
||||
>
|
||||
{dow}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
fontWeight: "bold",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
</Section>
|
||||
</Column>
|
||||
<Column style={{ paddingLeft: 16 }} align="left">
|
||||
<Text style={{ margin: 0, fontWeight: "bold" }}>{date}</Text>
|
||||
<Text light={true} style={{ margin: 0 }}>
|
||||
{time}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Text>
|
||||
We've notified participants and send them calendar invites.
|
||||
</Text>
|
||||
|
|
|
@ -1,48 +1,76 @@
|
|||
import { EmailLayout } from "./components/email-layout";
|
||||
import { Button, Card, Text } from "./components/styled-components";
|
||||
import { Column, Row, Section } from "@react-email/components";
|
||||
|
||||
export interface FinalizeHostEmailProps {
|
||||
import { EmailLayout } from "./components/email-layout";
|
||||
import { borderColor, Button, Text } from "./components/styled-components";
|
||||
|
||||
export interface FinalizeParticipantEmailProps {
|
||||
date: string;
|
||||
day: string;
|
||||
dow: string;
|
||||
time: string;
|
||||
name: string;
|
||||
title: string;
|
||||
hostName: string;
|
||||
location: string | null;
|
||||
pollUrl: string;
|
||||
attendees: string[];
|
||||
}
|
||||
|
||||
export const FinalizeHostEmail = ({
|
||||
export const FinalizeParticipantEmail = ({
|
||||
name = "Guest",
|
||||
title = "Untitled Poll",
|
||||
location,
|
||||
hostName = "Host",
|
||||
pollUrl = "https://rallly.co",
|
||||
attendees = ["Luke", "Leia", "Han"],
|
||||
date = "Friday, 12th June 2020 at 12:00pm",
|
||||
}: FinalizeHostEmailProps) => {
|
||||
day = "12",
|
||||
dow = "Fri",
|
||||
date = "Friday, 12th June 2020",
|
||||
time = "6:00 PM to 11:00 PM BST",
|
||||
}: FinalizeParticipantEmailProps) => {
|
||||
return (
|
||||
<EmailLayout recipientName={name} preview="Final date booked!">
|
||||
<Text>You poll has been finalized.</Text>
|
||||
<Card>
|
||||
<Text>
|
||||
<strong>Title</strong>
|
||||
<br />
|
||||
{title}
|
||||
<strong>{hostName}</strong> has booked <strong>{title}</strong> for the
|
||||
following date:
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>Date</strong>
|
||||
<br />
|
||||
{date}
|
||||
<Section>
|
||||
<Row>
|
||||
<Column style={{ width: 48 }}>
|
||||
<Section
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
margin: 0,
|
||||
width: 48,
|
||||
height: 48,
|
||||
textAlign: "center",
|
||||
border: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ margin: "0 0 4px 0", fontSize: 10, lineHeight: 1 }}
|
||||
>
|
||||
{dow}
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>Location</strong>
|
||||
<br />
|
||||
{location || "No location specified"}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
fontWeight: "bold",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>{`${attendees.length} attendees`}</strong>
|
||||
<br />
|
||||
{attendees.join(", ")}
|
||||
</Section>
|
||||
</Column>
|
||||
<Column style={{ paddingLeft: 16 }} align="left">
|
||||
<Text style={{ margin: 0, fontWeight: "bold" }}>{date}</Text>
|
||||
<Text light={true} style={{ margin: 0 }}>
|
||||
{time}
|
||||
</Text>
|
||||
</Card>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Text>Please find attached a calendar invite for this event.</Text>
|
||||
<Text>
|
||||
<Button href={pollUrl}>View Event</Button>
|
||||
</Text>
|
||||
|
@ -50,4 +78,4 @@ export const FinalizeHostEmail = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default FinalizeHostEmail;
|
||||
export default FinalizeParticipantEmail;
|
||||
|
|
|
@ -22,9 +22,14 @@ module.exports = {
|
|||
primary: {
|
||||
...colors.indigo,
|
||||
DEFAULT: colors.indigo["600"],
|
||||
foreground: colors.indigo["600"],
|
||||
foreground: colors.white,
|
||||
background: colors.indigo["50"],
|
||||
},
|
||||
secondary: {
|
||||
background: colors.gray["100"],
|
||||
DEFAULT: colors.gray["100"],
|
||||
foreground: colors.gray["800"],
|
||||
},
|
||||
gray: colors.gray,
|
||||
border: colors.gray["200"],
|
||||
input: {
|
||||
|
|
34
packages/ui/badge.tsx
Normal file
34
packages/ui/badge.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
85
packages/ui/billing-plan.tsx
Normal file
85
packages/ui/billing-plan.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { CheckIcon } from "@rallly/icons";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
const billingPlanVariants = cva(
|
||||
"border flex flex-col rounded-md shadow-sm overflow-hidden divide-y",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-white",
|
||||
default: "bg-gray-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const BillingPlan = ({
|
||||
variant = "default",
|
||||
children,
|
||||
}: React.PropsWithChildren<VariantProps<typeof billingPlanVariants>>) => {
|
||||
return <div className={billingPlanVariants({ variant })}>{children}</div>;
|
||||
};
|
||||
|
||||
export const BillingPlanHeader = ({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<{ className?: string }>) => {
|
||||
return <div className={cn("px-4 py-3", className)}>{children}</div>;
|
||||
};
|
||||
|
||||
export const BillingPlanTitle = ({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<{ className?: string }>) => {
|
||||
return (
|
||||
<h1 className={cn("mb-2 text-xl font-bold tracking-tight", className)}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export const BillingPlanPrice = ({
|
||||
children,
|
||||
discount,
|
||||
}: React.PropsWithChildren<{ discount?: React.ReactNode }>) => {
|
||||
return (
|
||||
<div>
|
||||
{discount ? (
|
||||
<>
|
||||
<span className="mr-2 text-xl font-bold line-through">
|
||||
{children}
|
||||
</span>
|
||||
<span className="text-3xl font-bold">{discount}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-3xl font-bold">{children}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BillingPlanPeriod = ({ children }: React.PropsWithChildren) => {
|
||||
return <div className="text-muted-foreground text-sm">{children}</div>;
|
||||
};
|
||||
|
||||
export const BillingPlanPerks = ({ children }: React.PropsWithChildren) => {
|
||||
return <ul className="grow space-y-1 p-4 text-sm">{children}</ul>;
|
||||
};
|
||||
|
||||
export const BillingPlanPerk = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<li className="flex items-center gap-x-2 ">
|
||||
<CheckIcon className="h-4 w-4 text-green-600" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const BillingPlanFooter = ({ children }: React.PropsWithChildren) => {
|
||||
return <div className="p-4">{children}</div>;
|
||||
};
|
44
packages/ui/button-group.tsx
Normal file
44
packages/ui/button-group.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
"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 };
|
|
@ -60,7 +60,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(buttonVariants({ variant, size, className }), {
|
||||
"pointer-events-none": loading,
|
||||
})}
|
||||
ref={ref}
|
||||
type={type}
|
||||
{...props}
|
||||
|
|
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-lg border shadow-sm",
|
||||
"bg-card text-card-foreground overflow-hidden rounded-lg border shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
10
turbo.json
10
turbo.json
|
@ -52,27 +52,33 @@
|
|||
"ANALYZE",
|
||||
"API_SECRET",
|
||||
"AUTH_REQUIRED",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_REGION",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"DISABLE_LANDING_PAGE",
|
||||
"EMAIL_PROVIDER",
|
||||
"MAINTENANCE_MODE",
|
||||
"NEXT_PUBLIC_APP_BASE_URL",
|
||||
"NEXT_PUBLIC_APP_VERSION",
|
||||
"NEXT_PUBLIC_BASE_URL",
|
||||
"NEXT_PUBLIC_BETA",
|
||||
"NEXT_PUBLIC_CRISP_WEBSITE_ID",
|
||||
"NEXT_PUBLIC_ENABLE_ANALYTICS",
|
||||
"NEXT_PUBLIC_ENABLE_FINALIZATION",
|
||||
"NEXT_PUBLIC_FEEDBACK_EMAIL",
|
||||
"NEXT_PUBLIC_LANDING_PAGE_URL",
|
||||
"NEXT_PUBLIC_MAINTENANCE_MODE",
|
||||
"NEXT_PUBLIC_PADDLE_SANDBOX",
|
||||
"NEXT_PUBLIC_PADDLE_VENDOR_ID",
|
||||
"NEXT_PUBLIC_POSTHOG_API_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_API_KEY",
|
||||
"NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY",
|
||||
"NEXT_PUBLIC_PRO_PLAN_ID_YEARLY",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"NEXT_PUBLIC_VERCEL_URL",
|
||||
"NEXT_PUBLIC_FEEDBACK_EMAIL",
|
||||
"NODE_ENV",
|
||||
"NOREPLY_EMAIL",
|
||||
"PADDLE_PUBLIC_KEY",
|
||||
"PORT",
|
||||
"SECRET_PASSWORD",
|
||||
"SENTRY_AUTH_TOKEN",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue