Better Pay-Wall Experience (#1357)

This commit is contained in:
Luke Vella 2024-09-20 22:33:02 +01:00 committed by GitHub
parent 8e68a50caa
commit 39e15dd9eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 219 additions and 249 deletions

View file

@ -241,13 +241,6 @@
"dangerZoneAccount": "Delete your account permanently. This action cannot be undone.", "dangerZoneAccount": "Delete your account permanently. This action cannot be undone.",
"upgradePromptTitle": "Upgrade to Pro", "upgradePromptTitle": "Upgrade to Pro",
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.", "upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
"finalizeFeatureDescription": "Select a final date for your event and notify participants.",
"duplicateTitle": "Duplicate",
"duplicateFeatureDescription": "Reuse dates and settings of a poll to create a new one.",
"advancedSettingsTitle": "Advanced Settings",
"advancedSettingsDescription": "Hide participants, hide scores, require participant email address.",
"keepPollsIndefinitely": "Keep Polls Indefinitely",
"keepPollsIndefinitelyDescription": "Inactive polls will not be auto-deleted.",
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>", "verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
"home": "Home", "home": "Home",
"groupPoll": "Group Poll", "groupPoll": "Group Poll",
@ -282,5 +275,14 @@
"fileTooLarge": "File too large", "fileTooLarge": "File too large",
"fileTooLargeDescription": "Please upload a file smaller than 2MB.", "fileTooLargeDescription": "Please upload a file smaller than 2MB.",
"errorUploadPicture": "Failed to upload", "errorUploadPicture": "Failed to upload",
"errorUploadPictureDescription": "There was an issue uploading your picture. Please try again later." "errorUploadPictureDescription": "There was an issue uploading your picture. Please try again later.",
"featureNameFinalize": "Finalize Poll",
"featureNameDuplicate": "Duplicate Poll",
"featureNameAdvancedSettings": "Advanced Settings",
"featureNameExtendedPollLifetime": "Extended Poll Lifetime",
"12months": "12 months",
"savePercentage": "Save {percentage}%",
"1month": "1 month",
"subscribe": "Subscribe",
"cancelAnytime": "Cancel anytime from your <a>billing page</a>."
} }

View file

@ -1,33 +1,38 @@
import { pricingData } from "@rallly/billing/pricing";
import { Badge } from "@rallly/ui/badge"; import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button"; import { Dialog, DialogContent, DialogProps } from "@rallly/ui/dialog";
import { DialogClose, DialogContent } from "@rallly/ui/dialog"; import { RadioGroup, RadioGroupItem } from "@rallly/ui/radio-group";
import { m } from "framer-motion"; import { m } from "framer-motion";
import { import { CheckIcon } from "lucide-react";
CalendarCheck2Icon,
ClockIcon,
CopyIcon,
Settings2Icon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan"; import { UpgradeButton } from "@/components/upgrade-button";
export function PayWallDialogContent({ const annualSavingsPercentage = (
children, ((pricingData.monthly.amount * 12 - pricingData.yearly.amount) /
}: { (pricingData.monthly.amount * 12)) *
children?: React.ReactNode; 100
}) { ).toFixed(0);
const plan = usePlan();
if (plan === "free") { const yearlyPrice = (pricingData.yearly.amount / 100).toFixed(2);
return ( const monthlyPrice = (pricingData.monthly.amount / 100).toFixed(2);
const monthlyPriceAnnualRate = (pricingData.yearly.amount / 100 / 12).toFixed(
2,
);
export function PayWallDialogContent(props: DialogProps) {
const [period, setPeriod] = useState("yearly");
return (
<Dialog {...props}>
<DialogContent className="w-[600px] p-4"> <DialogContent className="w-[600px] p-4">
<article> <div className="space-y-6">
<header className="p-4"> <header className="pt-4">
<m.div <m.div
transition={{ transition={{
delay: 0.5, delay: 0.2,
duration: 0.4, duration: 0.4,
type: "spring", type: "spring",
bounce: 0.5, bounce: 0.5,
@ -41,111 +46,108 @@ export function PayWallDialogContent({
<Trans i18nKey="planPro" /> <Trans i18nKey="planPro" />
</Badge> </Badge>
</m.div> </m.div>
<h1 className="mb-1 mt-2 text-center text-xl font-bold"> <h1 className="mb-2 mt-4 text-center text-xl font-bold">
<Trans defaults="Upgrade to Pro" i18nKey="upgradePromptTitle" /> <Trans defaults="Upgrade to Pro" i18nKey="upgradePromptTitle" />
</h1> </h1>
<p className="text-muted-foreground text-center text-sm leading-relaxed"> <p className="text-muted-foreground mb-4 text-center text-sm leading-relaxed">
<Trans <Trans
i18nKey="upgradeOverlaySubtitle3" i18nKey="upgradeOverlaySubtitle3"
defaults="Unlock these feature by upgrading to a Pro plan." defaults="Unlock these feature by upgrading to a Pro plan."
/> />
</p> </p>
</header> <ul className="grid grid-cols-2 justify-center gap-2 text-center text-sm font-medium">
<section className="rounded-lg border bg-gray-50"> <li>
<ul className="divide-y text-left"> <CheckIcon className="mr-2 inline-block size-4 text-green-600" />
<li className="flex items-start gap-x-4 p-4"> <Trans i18nKey="featureNameFinalize" defaults="Finalize Poll" />
<div>
<div className="inline-flex rounded-lg bg-indigo-100 p-2">
<CalendarCheck2Icon className="size-4 text-indigo-600" />
</div>
</div>
<div>
<h3 className="mb-1 text-sm font-semibold">
<Trans defaults="Finalize" i18nKey="finalize" />
</h3>
<p className="text-muted-foreground text-pretty text-sm leading-relaxed">
<Trans
i18nKey="finalizeFeatureDescription"
defaults="Select a final date for your event and notify participants."
/>
</p>
</div>
</li> </li>
<li className="flex items-start gap-x-4 p-4"> <li>
<div className="inline-flex rounded-lg bg-violet-100 p-2"> <CheckIcon className="mr-2 inline-block size-4 text-green-600" />
<CopyIcon className="size-4 text-violet-600" /> <Trans
</div> i18nKey="featureNameDuplicate"
<div> defaults="Duplicate Poll"
<h3 className="mb-1 text-sm font-semibold"> />
<Trans defaults="Duplicate" i18nKey="duplicateTitle" />
</h3>
<p className="text-muted-foreground leading-rel text-pretty text-sm">
<Trans
i18nKey="duplicateFeatureDescription"
defaults="Reuse dates and settings of a poll to create a new one."
/>
</p>
</div>
</li> </li>
<li className="flex items-start gap-x-4 p-4"> <li>
<div> <CheckIcon className="mr-2 inline-block size-4 text-green-600" />
<div className="inline-flex rounded-lg bg-purple-100 p-2"> <Trans
<Settings2Icon className="size-4 text-purple-600" /> i18nKey="featureNameAdvancedSettings"
</div> defaults="Advanced Settings"
</div> />
<div>
<h3 className="mb-1 text-sm font-semibold">
<Trans
defaults="Advanced Settings"
i18nKey="advancedSettingsTitle"
/>
</h3>
<p className="text-muted-foreground leading-rel text-pretty text-sm">
<Trans
i18nKey="advancedSettingsDescription"
defaults="Hide participants, hide scores, require participant email address."
/>
</p>
</div>
</li> </li>
<li className="flex items-start gap-x-4 p-4"> <li>
<div> <CheckIcon className="mr-2 inline-block size-4 text-green-600" />
<div className="inline-flex rounded-lg bg-pink-100 p-2"> <Trans
<ClockIcon className="size-4 text-pink-600" /> i18nKey="featureNameExtendedPollLifetime"
</div> defaults="Extended Poll Lifetime"
</div> />
<div>
<h3 className="mb-1 text-sm font-semibold">
<Trans
defaults="Keep Polls Indefinitely"
i18nKey="keepPollsIndefinitely"
/>
</h3>
<p className="text-muted-foreground leading-rel text-pretty text-sm">
<Trans
i18nKey="keepPollsIndefinitelyDescription"
defaults="Inactive polls will not be auto-deleted."
/>
</p>
</div>
</li> </li>
</ul> </ul>
</header>
<section>
<RadioGroup value={period} onValueChange={setPeriod}>
<li className="focus-within:ring-primary relative flex items-center justify-between rounded-lg border bg-gray-50 p-4 focus-within:ring-2">
<div className="space-y-1">
<div className="flex items-center gap-4">
<RadioGroupItem id="yearly" value="yearly" />
<label className="text-base font-semibold" htmlFor="yearly">
<span role="presentation" className="absolute inset-0" />
<Trans defaults="12 months" i18nKey="12months" />
</label>
<Badge variant="green">
<Trans
defaults="Save {percentage}%"
i18nKey="savePercentage"
values={{ percentage: annualSavingsPercentage }}
/>
</Badge>
</div>
<p className="text-muted-foreground flex items-baseline gap-1.5 pl-8 text-sm">
<span>${yearlyPrice}</span>
<span className="line-through opacity-50">
${((pricingData.monthly.amount * 12) / 100).toFixed(2)}
</span>
</p>
</div>
<p className="flex items-baseline gap-1">
<span className="text-xl font-semibold">
${monthlyPriceAnnualRate}
</span>
<span className="text-muted-foreground text-sm">/ mo</span>
</p>
</li>
<li className="focus-within:ring-primary relative flex items-center justify-between rounded-lg border bg-gray-50 p-4 focus-within:ring-2">
<div className="flex items-center gap-4">
<RadioGroupItem id="monthly" value="monthly" />
<label className="text-base font-semibold" htmlFor="monthly">
<span role="presentation" className="absolute inset-0" />
<Trans defaults="1 month" i18nKey="1month" />
</label>
</div>
<p className="flex items-baseline gap-1">
<span className="text-xl font-semibold">${monthlyPrice}</span>
<span className="text-muted-foreground text-sm">/ mo</span>
</p>
</li>
</RadioGroup>
</section> </section>
<footer className="mt-4 grid gap-2.5"> <footer className="space-y-4">
<Button variant="primary" asChild> <div className="grid gap-2">
<Link href="/settings/billing"> <UpgradeButton large annual={period === "yearly"}>
<Trans i18nKey="upgrade" defaults="Upgrade" /> <Trans i18nKey="subscribe" defaults="Subscribe" />
</Link> </UpgradeButton>
</Button> </div>
<DialogClose asChild> <p className="text-muted-foreground text-center text-sm">
<Button variant="ghost"> <Trans
<Trans i18nKey="notToday" defaults="Not Today" /> i18nKey="cancelAnytime"
</Button> defaults="Cancel anytime from your <a>billing page</a>."
</DialogClose> components={{
a: <Link className="text-link" href="/settings/billing" />,
}}
/>
</p>
</footer> </footer>
</article> </div>
</DialogContent> </DialogContent>
); </Dialog>
} );
return <>{children}</>;
} }

View file

@ -27,11 +27,14 @@ import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog"; import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
import { PayWallDialogContent } from "@/app/[locale]/poll/[urlId]/pay-wall-dialog-content";
import { trpc } from "@/app/providers"; import { trpc } from "@/app/providers";
import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog"; import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog";
import { ProFeatureBadge } from "@/components/pro-feature-badge"; import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { usePostHog } from "@/utils/posthog";
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog"; import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
import { useCsvExporter } from "./manage-poll/use-csv-exporter"; import { useCsvExporter } from "./manage-poll/use-csv-exporter";
@ -144,6 +147,9 @@ const ManagePoll: React.FunctionComponent<{
const [showDeletePollDialog, setShowDeletePollDialog] = React.useState(false); const [showDeletePollDialog, setShowDeletePollDialog] = React.useState(false);
const duplicateDialog = useDialog(); const duplicateDialog = useDialog();
const finalizeDialog = useDialog(); const finalizeDialog = useDialog();
const paywallDialog = useDialog();
const plan = usePlan();
const posthog = usePostHog();
const { exportToCsv } = useCsvExporter(); const { exportToCsv } = useCsvExporter();
return ( return (
@ -161,37 +167,6 @@ const ManagePoll: React.FunctionComponent<{
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<>
{poll.status === "finalized" ? (
<DropdownMenuItem
onClick={() => {
reopen.mutate({ pollId: poll.id });
}}
>
<Icon>
<RotateCcwIcon />
</Icon>
<Trans i18nKey="reopenPoll" defaults="Reopen" />
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
disabled={!!poll.event}
onClick={() => {
finalizeDialog.trigger();
}}
>
<Icon>
<CalendarCheck2Icon />
</Icon>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProFeatureBadge />
</DropdownMenuItem>
<PauseResumeToggle />
</>
)}
</>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/poll/${poll.id}/edit-details`}> <Link href={`/poll/${poll.id}/edit-details`}>
<DropdownMenuItemIconLabel icon={PencilIcon}> <DropdownMenuItemIconLabel icon={PencilIcon}>
@ -214,6 +189,44 @@ const ManagePoll: React.FunctionComponent<{
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<>
{poll.status === "finalized" ? (
<DropdownMenuItem
onClick={() => {
reopen.mutate({ pollId: poll.id });
}}
>
<Icon>
<RotateCcwIcon />
</Icon>
<Trans i18nKey="reopenPoll" defaults="Reopen" />
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
disabled={!!poll.event}
onClick={() => {
if (plan === "free") {
paywallDialog.trigger();
posthog?.capture("trigger paywall", {
poll_id: poll.id,
action: "finalize",
});
} else {
finalizeDialog.trigger();
}
}}
>
<Icon>
<CalendarCheck2Icon />
</Icon>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProFeatureBadge />
</DropdownMenuItem>
<PauseResumeToggle />
</>
)}
</>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={exportToCsv}> <DropdownMenuItem onClick={exportToCsv}>
<DropdownMenuItemIconLabel icon={DownloadIcon}> <DropdownMenuItemIconLabel icon={DownloadIcon}>
@ -222,7 +235,15 @@ const ManagePoll: React.FunctionComponent<{
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
duplicateDialog.trigger(); if (plan === "free") {
paywallDialog.trigger();
posthog?.capture("trigger paywall", {
poll_id: poll.id,
action: "duplicate",
});
} else {
duplicateDialog.trigger();
}
}} }}
> >
<DropdownMenuItemIconLabel icon={CopyIcon}> <DropdownMenuItemIconLabel icon={CopyIcon}>
@ -253,6 +274,7 @@ const ManagePoll: React.FunctionComponent<{
{...duplicateDialog.dialogProps} {...duplicateDialog.dialogProps}
/> />
<FinalizePollDialog {...finalizeDialog.dialogProps} /> <FinalizePollDialog {...finalizeDialog.dialogProps} />
<PayWallDialogContent {...paywallDialog.dialogProps} />
</> </>
); );
}; };

View file

@ -24,7 +24,6 @@ import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PayWallDialogContent } from "@/app/[locale]/poll/[urlId]/pay-wall-dialog-content";
import { trpc } from "@/app/providers"; import { trpc } from "@/app/providers";
import { DateIconInner } from "@/components/date-icon"; import { DateIconInner } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
@ -206,61 +205,6 @@ export const FinalizePollForm = ({
); );
}} }}
/> />
{/* <FormField
control={form.control}
name="notify"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="notify" className="mb-4">
<Trans i18nKey="notify" defaults="Send a calendar invite to" />
</FormLabel>
<FormControl>
<RadioGroup onValueChange={field.onChange} value={field.value}>
<Label className="flex items-center gap-4 font-normal">
<RadioGroupItem value="all" />
<Trans
i18nKey="notifyAllParticipants"
defaults="Everyone"
/>
</Label>
<Label className="flex items-center gap-4 font-normal">
<RadioGroupItem value="attendees" />
<Trans
i18nKey="notifyAvailableParticipants"
defaults="Attendees"
/>
</Label>
<Label className="flex items-center gap-4 font-normal">
<RadioGroupItem value="none" />
<Trans i18nKey="notifyNo" defaults="None" />
</Label>
</RadioGroup>
</FormControl>
<FormDescription>
<Trans
i18nKey="notifyDescription"
defaults="Choose which participants should receive a calendar invite"
/>
</FormDescription>
{participantsWithoutEmails.length ? (
<Alert>
<AlertCircleIcon className="size-4" />
<AlertDescription>
<Trans
i18nKey="missingEmailsAlert"
defaults="The following participants have not provided an email address."
/>
</AlertDescription>
<AlertDescription>
{participantsWithoutEmails.map((participant) => (
<div key={participant.id}>{participant.name}</div>
))}
</AlertDescription>
</Alert>
) : null}
</FormItem>
)}
/> */}
</form> </form>
</Form> </Form>
); );
@ -276,47 +220,45 @@ export function FinalizePollDialog(props: DialogProps) {
}); });
return ( return (
<Dialog {...props}> <Dialog {...props}>
<PayWallDialogContent> <DialogContent size="2xl">
<DialogContent size="2xl"> <DialogHeader>
<DialogHeader> <DialogTitle>
<DialogTitle> <Trans i18nKey="finalize" />
<Trans i18nKey="finalize" /> </DialogTitle>
</DialogTitle> <DialogDescription>
<DialogDescription> <Trans
<Trans i18nKey="finalizeDescription"
i18nKey="finalizeDescription" defaults="Select a final date for your event."
defaults="Select a final date for your event." />
/> </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader> <FinalizePollForm
<FinalizePollForm name="finalize-form"
name="finalize-form" onSubmit={(data) => {
onSubmit={(data) => { scheduleEvent.mutate({
scheduleEvent.mutate({ pollId: poll.id,
pollId: poll.id, optionId: data.selectedOptionId,
optionId: data.selectedOptionId, notify: data.notify,
notify: data.notify, });
}); props.onOpenChange?.(false);
props.onOpenChange?.(false); }}
}} />
/> <DialogFooter>
<DialogFooter> <DialogClose asChild>
<DialogClose asChild> <Button>
<Button> <Trans i18nKey="cancel" />
<Trans i18nKey="cancel" />
</Button>
</DialogClose>
<Button
loading={scheduleEvent.isLoading}
type="submit"
form="finalize-form"
variant="primary"
>
<Trans i18nKey="finalize" />
</Button> </Button>
</DialogFooter> </DialogClose>
</DialogContent> <Button
</PayWallDialogContent> loading={scheduleEvent.isLoading}
type="submit"
form="finalize-form"
variant="primary"
>
<Trans i18nKey="finalize" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
); );
} }

View file

@ -8,7 +8,8 @@ import { usePostHog } from "@/utils/posthog";
export const UpgradeButton = ({ export const UpgradeButton = ({
children, children,
annual, annual,
}: React.PropsWithChildren<{ annual?: boolean }>) => { large,
}: React.PropsWithChildren<{ annual?: boolean; large?: boolean }>) => {
const posthog = usePostHog(); const posthog = usePostHog();
return ( return (
@ -24,6 +25,7 @@ export const UpgradeButton = ({
value={window.location.pathname} value={window.location.pathname}
/> />
<Button <Button
size={large ? "lg" : "default"}
className="w-full" className="w-full"
type="submit" type="submit"
variant="primary" variant="primary"