From 5172ab11e76f6ab3e36fd445096bca34969ddd7b Mon Sep 17 00:00:00 2001 From: m5r <mokht@rmi.al> Date: Fri, 1 Oct 2021 00:18:03 +0200 Subject: [PATCH] pricing plans in settings --- .../components/billing/billing-history.tsx | 99 +++++++ .../components/{ => billing}/paddle-link.tsx | 0 app/settings/components/billing/plans.tsx | 126 +++++++++ app/settings/components/divider.tsx | 9 + app/settings/hooks/use-subscription.ts | 9 +- app/settings/pages/settings/billing.tsx | 253 +++--------------- 6 files changed, 283 insertions(+), 213 deletions(-) create mode 100644 app/settings/components/billing/billing-history.tsx rename app/settings/components/{ => billing}/paddle-link.tsx (100%) create mode 100644 app/settings/components/billing/plans.tsx create mode 100644 app/settings/components/divider.tsx diff --git a/app/settings/components/billing/billing-history.tsx b/app/settings/components/billing/billing-history.tsx new file mode 100644 index 0000000..f2da704 --- /dev/null +++ b/app/settings/components/billing/billing-history.tsx @@ -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> + ); +} diff --git a/app/settings/components/paddle-link.tsx b/app/settings/components/billing/paddle-link.tsx similarity index 100% rename from app/settings/components/paddle-link.tsx rename to app/settings/components/billing/paddle-link.tsx diff --git a/app/settings/components/billing/plans.tsx b/app/settings/components/billing/plans.tsx new file mode 100644 index 0000000..4f80bea --- /dev/null +++ b/app/settings/components/billing/plans.tsx @@ -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, + }, + ], +}; diff --git a/app/settings/components/divider.tsx b/app/settings/components/divider.tsx new file mode 100644 index 0000000..8c78520 --- /dev/null +++ b/app/settings/components/divider.tsx @@ -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> + ); +} diff --git a/app/settings/hooks/use-subscription.ts b/app/settings/hooks/use-subscription.ts index 1719aa4..ed247e6 100644 --- a/app/settings/hooks/use-subscription.ts +++ b/app/settings/hooks/use-subscription.ts @@ -1,15 +1,20 @@ import { useEffect, useRef } from "react"; import { useQuery, useMutation, useRouter, useSession } from "blitz"; +import type { Subscription } from "db"; import getSubscription from "../queries/get-subscription"; import usePaddle from "./use-paddle"; import useCurrentUser from "../../core/hooks/use-current-user"; import updateSubscription from "../mutations/update-subscription"; -export default function useSubscription() { +type Params = { + initialData?: Subscription; +}; + +export default function useSubscription({ initialData }: Params = {}) { const session = useSession(); 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 router = useRouter(); diff --git a/app/settings/pages/settings/billing.tsx b/app/settings/pages/settings/billing.tsx index e94bd70..caede72 100644 --- a/app/settings/pages/settings/billing.tsx +++ b/app/settings/pages/settings/billing.tsx @@ -1,63 +1,24 @@ import type { BlitzPage } from "blitz"; -import { GetServerSideProps, Link, Routes } from "blitz"; -import * as Panelbear from "@panelbear/panelbear-js"; -import clsx from "clsx"; +import { GetServerSideProps, getSession, Routes } from "blitz"; +import db, { Subscription, SubscriptionStatus } from "db"; import useSubscription from "../../hooks/use-subscription"; import useRequireOnboarding from "../../../core/hooks/use-require-onboarding"; import SettingsLayout from "../../components/settings-layout"; -import appLogger from "../../../../integrations/logger"; -import PaddleLink from "../../components/paddle-link"; 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 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", - 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, - }, - ], +type Props = { + subscription?: Subscription; }; -const Billing: BlitzPage = () => { +const Billing: BlitzPage<Props> = (props) => { /* TODO: I want to be able to - subscribe @@ -70,81 +31,16 @@ const Billing: BlitzPage = () => { */ useRequireOnboarding(); - const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription(); - console.log("subscription", subscription); + const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription({ + initialData: props.subscription, + }); if (!subscription) { return ( - <SettingsSection> - <div> - <h2 className="text-lg leading-6 font-medium text-gray-900">Subscribe</h2> - <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> + <> + <Plans /> + <p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p> + </> ); } @@ -153,100 +49,22 @@ const Billing: BlitzPage = () => { <SettingsSection> <PaddleLink onClick={() => updatePaymentMethod({ updateUrl: subscription.updateUrl })} - text="Update payment method on Paddle" + text="Update payment method" /> - </SettingsSection> - - <SettingsSection>{/*<BillingPlans activePlanId={subscription.paddlePlanId} />*/}</SettingsSection> - - <SettingsSection> <PaddleLink onClick={() => cancelSubscription({ cancelUrl: subscription.cancelUrl })} - text="Cancel subscription on Paddle" + text="Cancel subscription" /> </SettingsSection> - <section aria-labelledby="billing-history-heading"> - <div className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden"> - <div className="px-4 sm:px-6"> - <h2 id="billing-history-heading" 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"> - {[ - { - 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> + <BillingHistory /> + + <div className="hidden lg:block lg:py-3"> + <Divider /> + </div> + + <Plans /> + <p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p> </> ); }; @@ -255,8 +73,21 @@ Billing.getLayout = (page) => <SettingsLayout>{page}</SettingsLayout>; Billing.authenticate = { redirectTo: Routes.SignIn() }; -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - return { props: {} }; +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: { subscription }, + }; }; export default Billing;