From aec66b701016b5212a6cf57896658dc64427d4c2 Mon Sep 17 00:00:00 2001 From: Dan Farrelly Date: Thu, 21 Nov 2024 15:28:47 -0500 Subject: [PATCH] Add basic pricing calculator --- app/pricing/PricingCalculator.tsx | 310 ++++++++++++++++++++++ app/pricing/page.tsx | 81 ++++++ pages/pricing.tsx => app/pricing/plans.ts | 122 ++------- app/pricing/types.ts | 54 ++++ shared/Pricing/ComparisonTable.tsx | 2 + shared/Pricing/PlanCard.tsx | 2 + 6 files changed, 469 insertions(+), 102 deletions(-) create mode 100644 app/pricing/PricingCalculator.tsx create mode 100644 app/pricing/page.tsx rename pages/pricing.tsx => app/pricing/plans.ts (87%) create mode 100644 app/pricing/types.ts diff --git a/app/pricing/PricingCalculator.tsx b/app/pricing/PricingCalculator.tsx new file mode 100644 index 000000000..057f5c4dc --- /dev/null +++ b/app/pricing/PricingCalculator.tsx @@ -0,0 +1,310 @@ +"use client"; +import { useState, useMemo } from "react"; +import { RiArrowDownSLine } from "@remixicon/react"; + +import classNames from "src/utils/classNames"; +import { type Plan, PLAN_NAMES, getPlan } from "./plans"; + +const FREE_PLAN = getPlan(PLAN_NAMES.basicFree); +const BASIC_PLAN = getPlan(PLAN_NAMES.basic); +const PRO_PLAN = getPlan(PLAN_NAMES.pro); + +type EstimatedCosts = { + baseCost: number; + totalCost: number; + additionalRunsCost: number; + additionalStepsCost: number; + concurrencyCost: number; +}; +type CalculatorResults = { + cost: EstimatedCosts; + includedSteps: number; + estimatedSteps: number; + plan: string; +}; + +function calculatePlanCost({ + planName, + runs, + steps, + concurrency, +}: { + planName: typeof PLAN_NAMES[keyof typeof PLAN_NAMES]; + runs: number; + steps: number; + concurrency: number; +}): EstimatedCosts { + const plan = getPlan(planName); + const additionalRuns = Math.max(runs - num(plan.cost.includedRuns), 0); + const includedSteps = runs * 5; + const additionalSteps = Math.max(steps - includedSteps, 0); + const additionalConcurrency = Math.max( + concurrency - num(plan.cost.includedConcurrency), + 0 + ); + + const baseCost = num(plan.cost.basePrice); + const additionalRunsCost = + Math.ceil(additionalRuns / num(plan.cost.additionalRunsRate)) * + num(plan.cost.additionalRunsPrice); + const additionalStepsCost = + Math.ceil(additionalSteps / num(plan.cost.additionalStepsRate)) * + num(plan.cost.additionalStepsPrice); + // If there is additional concurrency, but there is no available rate, + // the cost is NaN (which means it isn't possible) + const concurrencyCost = + additionalConcurrency === 0 + ? 0 + : additionalConcurrency >= 0 && plan.cost.additionalConcurrencyRate + ? Math.ceil( + additionalConcurrency / num(plan.cost.additionalConcurrencyRate) + ) * num(plan.cost.additionalConcurrencyPrice) + : NaN; + + const totalCost = + baseCost + additionalRunsCost + additionalStepsCost + concurrencyCost; + return { + baseCost, + totalCost, + additionalRunsCost, + additionalStepsCost, + concurrencyCost, + }; +} + +function calculatePlanCosts({ + runs, + steps, + concurrency, +}: { + runs: number; + steps: number; + concurrency: number; +}) { + return { + [PLAN_NAMES.basic]: { + cost: calculatePlanCost({ + planName: PLAN_NAMES.basic, + runs, + steps, + concurrency, + }), + }, + [PLAN_NAMES.pro]: { + cost: calculatePlanCost({ + planName: PLAN_NAMES.pro, + runs, + steps, + concurrency, + }), + }, + }; +} + +export default function PricingCalculator({ plans }: { plans: Plan[] }) { + const [isOpen, setOpen] = useState(false); + const [runsInput, setRunsInput] = useState("150,000"); + const [concurrencyInput, setConcurrencyInput] = useState("25"); + const [avgStepsInput, setAvgStepsInput] = useState("5"); + + const results: CalculatorResults = useMemo( + function () { + const runs = num(runsInput); + const steps = num(avgStepsInput) * runs; + const concurrency = num(concurrencyInput); + + if ( + runs <= num(FREE_PLAN.cost.includedRuns) && + steps <= + num(FREE_PLAN.cost.includedRuns) * + num(FREE_PLAN.cost.includedSteps) && + concurrency <= num(FREE_PLAN.cost.includedConcurrency) + ) { + return { + cost: { + baseCost: 0, + totalCost: 0, + additionalRunsCost: 0, + additionalStepsCost: 0, + concurrencyCost: 0, + }, + includedSteps: num(FREE_PLAN.cost.includedSteps), + estimatedSteps: steps, + plan: FREE_PLAN.name, + }; + } + const estimates = calculatePlanCosts({ + runs, + steps, + concurrency, + }); + + const recommendedPlan = + estimates[PLAN_NAMES.basic].cost.totalCost < + estimates[PLAN_NAMES.pro].cost.totalCost + ? PLAN_NAMES.basic + : estimates[PLAN_NAMES.pro].cost.totalCost < 2_000 + ? PLAN_NAMES.pro + : PLAN_NAMES.enterprise; + + return { + cost: estimates[recommendedPlan]?.cost ?? { + baseCost: Infinity, + totalCost: Infinity, + additionalRunsCost: Infinity, + additionalStepsCost: Infinity, + concurrencyCost: Infinity, + }, + includedSteps: runs * 5, + estimatedSteps: steps, + plan: recommendedPlan, + }; + }, + [runsInput, avgStepsInput, concurrencyInput] + ); + + return ( +
+
setOpen(!isOpen)} + > +

Pricing calculator

+ +
+ +
+
+
+
+ +
+
+ setRunsInput(e.target.value)} + name="runs" + /> +
+
Included steps (runs x 5)
+
+ {results.includedSteps.toLocaleString()} +
+
+ +
+
+ setAvgStepsInput(e.target.value)} + name="steps" + /> +
+
Estimated step usage
+
+ {results.estimatedSteps.toLocaleString()} +
+
+ +
+
+ setConcurrencyInput(e.target.value)} + name="concurrency" + /> +
+
+
+

+ Recommended plan: {results.plan} +

+

+ Estimated cost:{" "} + {results.cost.totalCost === Infinity + ? "Custom" + : `$${results.cost.totalCost}/mo.`} +

+ +
+
+
+
+ ); +} + +function Input(props) { + return ( + + ); +} +function Calculated({ children }: { children: React.ReactNode }) { + return ( +
+ {children} + CALCULATED +
+ ); +} + +function Calculations({ cost }: { cost: EstimatedCosts }) { + return ( +
+ + {cost.additionalRunsCost > 0 && ( + + )} + {cost.additionalStepsCost > 0 && ( + + )} + {cost.concurrencyCost > 0 && ( + + )} + +
+ ); +} +function CalculationsRow({ label, value, isTotal = false }) { + return ( + <> +
{label}
+
+ ${value} +
+ + ); +} + +function num(v: string | number): number { + if (typeof v === "string") { + const parsed = parseInt(v.replace(/,/g, ""), 10); + return Number.isNaN(parsed) ? 0 : parsed; + } + return v; +} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx new file mode 100644 index 000000000..79bb73393 --- /dev/null +++ b/app/pricing/page.tsx @@ -0,0 +1,81 @@ +import { type Metadata } from "next"; +import dynamic from "next/dynamic"; + +import { generateMetadata } from "src/utils/social"; +import PlanCard from "src/shared/Pricing/PlanCard"; +import CaseStudies from "src/shared/Pricing/CaseStudies"; +import { Button } from "src/shared/Button"; +import PricingCalculator from "./PricingCalculator"; + +import { PLANS, FEATURES, sections } from "./plans"; + +// Disable SSR in ComparisonTable, to prevent hydration errors. It requires windows info on accordions +const ComparisonTable = dynamic( + () => import("src/shared/Pricing/ComparisonTable"), + { + ssr: false, + } +); + +export const metadata: Metadata = generateMetadata({ + title: "Pricing", + description: + "Pricing plans that scale with you, from our Free Tier all the way to custom Enterprise pricing.", +}); + +export default function Pricing() { + return ( +
+ {/*
*/} +
+

+ Simple pricing that scales with you +

+

+ From early-stage startups to scaling enterprises, Inngest has you + covered. Get started for free today. +

+
+ {PLANS.filter((p) => p.hideFromCards !== true).map((p, idx) => ( + + ))} +
+ + + + +
+
+

+ Need help deciding which plan to choose? +

+ +
+ + {/*
*/} +
+ ); +} diff --git a/pages/pricing.tsx b/app/pricing/plans.ts similarity index 87% rename from pages/pricing.tsx rename to app/pricing/plans.ts index 85107e849..f4bce594b 100644 --- a/pages/pricing.tsx +++ b/app/pricing/plans.ts @@ -1,19 +1,3 @@ -import dynamic from "next/dynamic"; -import Header from "src/shared/Header"; -import Container from "src/shared/layout/Container"; -import PlanCard from "src/shared/Pricing/PlanCard"; -import CaseStudies from "src/shared/Pricing/CaseStudies"; -import Footer from "../shared/Footer"; -import { Button } from "src/shared/Button"; - -// Disable SSR in ComparisonTable, to prevent hydration errors. It requires windows info on accordions -const ComparisonTable = dynamic( - () => import("src/shared/Pricing/ComparisonTable"), - { - ssr: false, - } -); - export type Plan = { name: string; cost: { @@ -52,7 +36,7 @@ export type Plan = { features: string[]; }; -type Feature = { +export type Feature = { name: string; description?: string; section: @@ -69,27 +53,14 @@ type Feature = { infoUrl?: string; }; -export const sections: { key: string; name: string; description?: string }[] = [ - { key: "platform", name: "Platform" }, - { - key: "recovery", - name: "Recovery and management", - description: "Included with every plan", - }, - { key: "observability", name: "Observability" }, - { key: "data", name: "Time-based data management" }, - { key: "connectivity", name: "Connectivity" }, - { key: "organization", name: "Organization" }, -]; - -const PLAN_NAMES = { +export const PLAN_NAMES = { basicFree: "Free", basic: "Basic", pro: "Pro", enterprise: "Enterprise", }; -const PLANS: Plan[] = [ +export const PLANS: Plan[] = [ { name: PLAN_NAMES.basicFree, cost: { @@ -247,11 +218,26 @@ const PLANS: Plan[] = [ }, ]; -function getPlan(planName: string): Plan { +export function getPlan( + planName: typeof PLAN_NAMES[keyof typeof PLAN_NAMES] +): Plan { return PLANS.find((p) => p.name === planName); } -const FEATURES: Feature[] = [ +export const sections: { key: string; name: string; description?: string }[] = [ + { key: "platform", name: "Platform" }, + { + key: "recovery", + name: "Recovery and management", + description: "Included with every plan", + }, + { key: "observability", name: "Observability" }, + { key: "data", name: "Time-based data management" }, + { key: "connectivity", name: "Connectivity" }, + { key: "organization", name: "Organization" }, +]; + +export const FEATURES: Feature[] = [ { name: "Base price", plans: { @@ -672,71 +658,3 @@ const FEATURES: Feature[] = [ section: "organization", }, ]; - -export async function getStaticProps() { - return { - props: { - designVersion: "3", - meta: { - title: "Pricing", - description: "Simple pricing. Powerful functionality.", - }, - }, - }; -} - -export default function Pricing() { - return ( -
-
-
-

- Simple pricing that scales with you -

-

- From early-stage startups to scaling enterprises, Inngest has you - covered. Get started for free today. -

-
- {PLANS.filter((p) => p.hideFromCards !== true).map((p, idx) => ( - - ))} -
- - - -
-
-

- Need help deciding which plan to choose? -

- -
- -
- ); -} diff --git a/app/pricing/types.ts b/app/pricing/types.ts new file mode 100644 index 000000000..62fb026e7 --- /dev/null +++ b/app/pricing/types.ts @@ -0,0 +1,54 @@ +export type Plan = { + name: string; + cost: { + startsAt?: boolean; + between?: boolean; + // Use numbers for calculators + basePrice: number | string; + endPrice?: number; + includedRuns: number | string; + additionalRunsPrice: number | string | null; + additionalRunsRate?: number; + includedSteps: number | string; + additionalStepsPrice: number | string | null; + additionalStepsRate?: number; + includedConcurrency: number | string; + additionalConcurrencyPrice: number | string | null; + additionalConcurrencyRate?: number; + includedUsers: number | string; + additionalUsersPrice: number | string | null; + additionalUsersRate?: number; + period?: string; + }; + description: React.ReactFragment | string; + hideFromCards?: boolean; + recommended?: boolean; + primaryCTA?: boolean; + cta: { + href: string; + text: string; + }; + highlights: { + runs: string; + concurrency: string; + }; + planIncludes: string; + features: string[]; +}; + +export type Feature = { + name: string; + description?: string; + section: + | "platform" + | "recovery" + | "observability" + | "data" + | "connectivity" + | "organization"; + all?: boolean | string; // All plans offer this + plans?: { + [key: string]: string | boolean | { value: string; description?: string }; + }; + infoUrl?: string; +}; diff --git a/shared/Pricing/ComparisonTable.tsx b/shared/Pricing/ComparisonTable.tsx index cbeba129e..abdce693e 100644 --- a/shared/Pricing/ComparisonTable.tsx +++ b/shared/Pricing/ComparisonTable.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { useState, useEffect } from "react"; import { diff --git a/shared/Pricing/PlanCard.tsx b/shared/Pricing/PlanCard.tsx index 3613c592f..79e15d563 100644 --- a/shared/Pricing/PlanCard.tsx +++ b/shared/Pricing/PlanCard.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useState, useEffect } from "react"; import { Button } from "../Button"; import { type Plan } from "../../pages/pricing";