pricing plans in settings

This commit is contained in:
m5r 2021-10-01 00:18:03 +02:00
parent 13ac4a5580
commit 5172ab11e7
6 changed files with 283 additions and 213 deletions

View File

@ -0,0 +1,99 @@
const payments = [
{
id: 1,
date: new Date(),
description: "",
amount: "340 USD",
href: "",
},
{
id: 1,
date: new Date(),
description: "",
amount: "340 USD",
href: "",
},
{
id: 1,
date: new Date(),
description: "",
amount: "340 USD",
href: "",
},
];
export default function BillingHistory() {
return (
<section>
<div className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 sm:px-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Billing history</h2>
</div>
<div className="mt-6 flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="overflow-hidden border-t border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Description
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Amount
</th>
{/*
`relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile.
*/}
<th
scope="col"
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
<span className="sr-only">View receipt</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<time>{payment.date.toDateString()}</time>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{payment.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{payment.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a
href={payment.href}
className="text-primary-600 hover:text-primary-900"
>
View receipt
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,126 @@
import { HiCheck } from "react-icons/hi";
import * as Panelbear from "@panelbear/panelbear-js";
import clsx from "clsx";
import useSubscription from "../../hooks/use-subscription";
export default function Plans() {
const { subscription, subscribe } = useSubscription();
return (
<div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4">
{pricing.tiers.map((tier) => {
const isCurrentTier = subscription?.paddlePlanId === tier.planId;
const cta = isCurrentTier ? "Current plan" : !!subscription ? `Switch to ${tier.title}` : "Subscribe";
return (
<div
key={tier.title}
className={clsx(
tier.yearly && "mb-4",
"relative p-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-grow w-1/3 flex-col",
)}
>
<div className="flex-1">
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
{tier.yearly ? (
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
Get 2 months free!
</p>
) : null}
<p className="mt-4 flex items-baseline text-gray-900">
<span className="text-2xl font-extrabold tracking-tight">{tier.price}</span>
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
</p>
{tier.yearly ? (
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12})</p>
) : null}
<p className="mt-6 text-gray-500">{tier.description}</p>
<ul role="list" className="mt-6 space-y-6">
{tier.features.map((feature) => (
<li key={feature} className="flex">
<HiCheck className="flex-shrink-0 w-6 h-6 text-[#0eb56f]" aria-hidden="true" />
<span className="ml-3 text-gray-500">{feature}</span>
</li>
))}
{tier.unavailableFeatures.map((feature) => (
<li key={feature} className="flex">
<span className="ml-9 text-gray-400">
{~feature.indexOf("(coming soon)")
? feature.slice(0, feature.indexOf("(coming soon)"))
: feature}
</span>
</li>
))}
</ul>
</div>
<button
disabled={isCurrentTier}
onClick={() => {
subscribe({ planId: tier.planId });
Panelbear.track(`Subscribe to ${tier.title}`);
}}
className={clsx(
!isCurrentTier
? "bg-primary-500 text-white hover:bg-primary-600"
: "bg-primary-50 text-primary-700 cursor-not-allowed",
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
)}
>
{cta}
</button>
</div>
);
})}
</div>
);
}
const paidFeatures = [
"SMS",
"MMS (coming soon)",
"Calls",
"SMS forwarding (coming soon)",
"Call forwarding (coming soon)",
"Voicemail (coming soon)",
"Call recording (coming soon)",
];
const pricing = {
tiers: [
{
title: "Free",
planId: "free",
price: 0,
frequency: "",
description: "The essentials to let you try Shellphone.",
features: ["SMS (send only)"],
unavailableFeatures: paidFeatures.slice(1),
cta: "Subscribe",
yearly: false,
},
{
title: "Monthly",
planId: "727540",
price: 15,
frequency: "/month",
description: "Text and call anyone, anywhere in the world.",
features: paidFeatures,
unavailableFeatures: [],
cta: "Subscribe",
yearly: false,
},
{
title: "Yearly",
planId: "727544",
price: 12.5,
frequency: "/month",
description: "Text and call anyone, anywhere in the world, all year long.",
features: paidFeatures,
unavailableFeatures: [],
cta: "Subscribe",
yearly: true,
},
],
};

View File

@ -0,0 +1,9 @@
export default function Divider() {
return (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
</div>
);
}

View File

@ -1,15 +1,20 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useQuery, useMutation, useRouter, useSession } from "blitz"; import { useQuery, useMutation, useRouter, useSession } from "blitz";
import type { Subscription } from "db";
import getSubscription from "../queries/get-subscription"; import getSubscription from "../queries/get-subscription";
import usePaddle from "./use-paddle"; import usePaddle from "./use-paddle";
import useCurrentUser from "../../core/hooks/use-current-user"; import useCurrentUser from "../../core/hooks/use-current-user";
import updateSubscription from "../mutations/update-subscription"; import updateSubscription from "../mutations/update-subscription";
export default function useSubscription() { type Params = {
initialData?: Subscription;
};
export default function useSubscription({ initialData }: Params = {}) {
const session = useSession(); const session = useSession();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [subscription] = useQuery(getSubscription, null, { enabled: Boolean(session.orgId) }); const [subscription] = useQuery(getSubscription, null, { enabled: Boolean(session.orgId), initialData });
const [updateSubscriptionMutation] = useMutation(updateSubscription); const [updateSubscriptionMutation] = useMutation(updateSubscription);
const router = useRouter(); const router = useRouter();

View File

@ -1,63 +1,24 @@
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { GetServerSideProps, Link, Routes } from "blitz"; import { GetServerSideProps, getSession, Routes } from "blitz";
import * as Panelbear from "@panelbear/panelbear-js";
import clsx from "clsx";
import db, { Subscription, SubscriptionStatus } from "db";
import useSubscription from "../../hooks/use-subscription"; import useSubscription from "../../hooks/use-subscription";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
import SettingsLayout from "../../components/settings-layout"; import SettingsLayout from "../../components/settings-layout";
import appLogger from "../../../../integrations/logger";
import PaddleLink from "../../components/paddle-link";
import SettingsSection from "../../components/settings-section"; import SettingsSection from "../../components/settings-section";
import { HiCheck } from "react-icons/hi"; import Divider from "../../components/divider";
import PaddleLink from "../../components/billing/paddle-link";
import Plans from "../../components/billing/plans";
import BillingHistory from "../../components/billing/billing-history";
import appLogger from "../../../../integrations/logger";
const logger = appLogger.child({ page: "/account/settings/billing" }); const logger = appLogger.child({ page: "/account/settings/billing" });
const paidFeatures = [ type Props = {
"SMS", subscription?: Subscription;
"MMS (coming soon)",
"Calls",
"SMS forwarding (coming soon)",
"Call forwarding (coming soon)",
"Voicemail (coming soon)",
"Call recording (coming soon)",
];
const pricing = {
tiers: [
{
title: "Free",
price: 0,
frequency: "",
description: "The essentials to let you try Shellphone.",
features: ["SMS (send only)"],
unavailableFeatures: paidFeatures.slice(1),
cta: "Current tier",
yearly: false,
},
{
title: "Monthly",
price: 15,
frequency: "/month",
description: "Text and call anyone, anywhere in the world.",
features: paidFeatures,
unavailableFeatures: [],
cta: "Subscribe",
yearly: false,
},
{
title: "Yearly",
price: 12.5,
frequency: "/month",
description: "Text and call anyone, anywhere in the world, all year long.",
features: paidFeatures,
unavailableFeatures: [],
cta: "Subscribe",
yearly: true,
},
],
}; };
const Billing: BlitzPage = () => { const Billing: BlitzPage<Props> = (props) => {
/* /*
TODO: I want to be able to TODO: I want to be able to
- subscribe - subscribe
@ -70,81 +31,16 @@ const Billing: BlitzPage = () => {
*/ */
useRequireOnboarding(); useRequireOnboarding();
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription(); const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription({
console.log("subscription", subscription); initialData: props.subscription,
});
if (!subscription) { if (!subscription) {
return ( return (
<SettingsSection> <>
<div> <Plans />
<h2 className="text-lg leading-6 font-medium text-gray-900">Subscribe</h2> <p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p>
<p className="mt-1 text-sm text-gray-500"> </>
Update your billing information. Please note that updating your location could affect your tax
rates.
</p>
</div>
<div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4">
{pricing.tiers.map((tier) => (
<div
key={tier.title}
className="relative p-4 mb-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-grow w-1/3 flex-col"
>
<div className="flex-1">
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
{tier.yearly ? (
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
Get 2 months free!
</p>
) : null}
<p className="mt-4 flex items-baseline text-gray-900">
<span className="text-2xl font-extrabold tracking-tight">{tier.price}</span>
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
</p>
{tier.yearly ? (
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12})</p>
) : null}
<p className="mt-6 text-gray-500">{tier.description}</p>
<ul role="list" className="mt-6 space-y-6">
{tier.features.map((feature) => (
<li key={feature} className="flex">
<HiCheck
className="flex-shrink-0 w-6 h-6 text-[#0eb56f]"
aria-hidden="true"
/>
<span className="ml-3 text-gray-500">{feature}</span>
</li>
))}
{tier.unavailableFeatures.map((feature) => (
<li key={feature} className="flex">
<span className="ml-9 text-gray-400">
{~feature.indexOf("(coming soon)")
? feature.slice(0, feature.indexOf("(coming soon)"))
: feature}
</span>
</li>
))}
</ul>
</div>
<Link href={Routes.LandingPage({ join_waitlist: "" })}>
<a
onClick={() => Panelbear.track("redirect-to-join-waitlist")}
className={clsx(
tier.yearly
? "bg-primary-500 text-white hover:bg-primary-600"
: "bg-primary-50 text-primary-700 hover:bg-primary-100",
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
)}
>
{tier.cta}
</a>
</Link>
</div>
))}
</div>
</SettingsSection>
); );
} }
@ -153,100 +49,22 @@ const Billing: BlitzPage = () => {
<SettingsSection> <SettingsSection>
<PaddleLink <PaddleLink
onClick={() => updatePaymentMethod({ updateUrl: subscription.updateUrl })} onClick={() => updatePaymentMethod({ updateUrl: subscription.updateUrl })}
text="Update payment method on Paddle" text="Update payment method"
/> />
</SettingsSection>
<SettingsSection>{/*<BillingPlans activePlanId={subscription.paddlePlanId} />*/}</SettingsSection>
<SettingsSection>
<PaddleLink <PaddleLink
onClick={() => cancelSubscription({ cancelUrl: subscription.cancelUrl })} onClick={() => cancelSubscription({ cancelUrl: subscription.cancelUrl })}
text="Cancel subscription on Paddle" text="Cancel subscription"
/> />
</SettingsSection> </SettingsSection>
<section aria-labelledby="billing-history-heading"> <BillingHistory />
<div className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 sm:px-6"> <div className="hidden lg:block lg:py-3">
<h2 id="billing-history-heading" className="text-lg leading-6 font-medium text-gray-900"> <Divider />
Billing history
</h2>
</div> </div>
<div className="mt-6 flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <Plans />
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> <p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p>
<div className="overflow-hidden border-t border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Description
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Amount
</th>
{/*
`relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile.
*/}
<th
scope="col"
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
<span className="sr-only">View receipt</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{[
{
id: 1,
date: new Date(),
description: "",
amount: "340 USD",
href: "",
},
].map((payment) => (
<tr key={payment.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<time>{payment.date}</time>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{payment.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{payment.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a
href={payment.href}
className="text-primary-600 hover:text-primary-900"
>
View receipt
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
</> </>
); );
}; };
@ -255,8 +73,21 @@ Billing.getLayout = (page) => <SettingsLayout>{page}</SettingsLayout>;
Billing.authenticate = { redirectTo: Routes.SignIn() }; Billing.authenticate = { redirectTo: Routes.SignIn() };
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }) => {
const session = await getSession(req, res);
const subscription = await db.subscription.findFirst({
where: {
organizationId: session.orgId,
status: SubscriptionStatus.active,
},
});
if (!subscription) {
return { props: {} }; return { props: {} };
}
return {
props: { subscription },
};
}; };
export default Billing; export default Billing;