From f849bd4e60d10ae1065bdbd5362d3f57b9827eba Mon Sep 17 00:00:00 2001 From: Ravi <103035973+raviships@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:21:37 +0900 Subject: [PATCH] feat(pricing): redesign pricing page with a plan comparison table Co-Authored-By: Claude Opus 5.5 --- .../_components/campaign-countdown.tsx | 136 +--- .../(main)/pricing/_components/plan-tabs.tsx | 74 ++ .../(main)/pricing/_components/pricing.tsx | 680 +++++++++--------- 3 files changed, 454 insertions(+), 436 deletions(-) create mode 100644 docs/src/app/(main)/pricing/_components/plan-tabs.tsx diff --git a/docs/src/app/(main)/pricing/_components/campaign-countdown.tsx b/docs/src/app/(main)/pricing/_components/campaign-countdown.tsx index 6b00eae5..995f5bb0 100644 --- a/docs/src/app/(main)/pricing/_components/campaign-countdown.tsx +++ b/docs/src/app/(main)/pricing/_components/campaign-countdown.tsx @@ -1,115 +1,57 @@ 'use client'; +import { cn } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { type PricingInfo } from './pricing'; export function CampaignCountdown(props: { campaign: NonNullable; + className?: string; }) { - const { campaign } = props; - const [timeLeft, setTimeLeft] = useState(() => - getTimeDifference(new Date(), campaign.endDate), - ); + const { campaign, className } = props; + const endTime = new Date(campaign.endDate).getTime(); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { - const intervalId = setInterval(() => { - setTimeLeft(getTimeDifference(new Date(), campaign.endDate)); - }, 1000); - - return () => { - clearInterval(intervalId); - }; - }, [campaign.endDate]); + const intervalId = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(intervalId); + }, []); - if (timeLeft.diff < 0) { + const secondsLeft = Math.floor((endTime - now) / 1000); + if (secondsLeft < 0) { return null; } + const timeLeft = [ + [Math.floor(secondsLeft / 86400), 'd'], + [Math.floor(secondsLeft / 3600) % 24, 'h'], + [Math.floor(secondsLeft / 60) % 60, 'm'], + [secondsLeft % 60, 's'], + ] as const; + return ( -
-
-
-

- {campaign.name} Ends Soon -

-

- {campaign.description} -

-
-
-
-
- {timeLeft.days} -
-
- Days -
-
-
-
- {timeLeft.hours} -
-
- Hours -
-
-
-
- {timeLeft.minutes} -
-
- Minutes -
-
-
-
- {timeLeft.seconds} -
-
- Seconds -
-
-
-
+
+

+ {campaign.name}.{' '} + {campaign.description} +

+

+ Ends in{' '} + {timeLeft.map(([value, unit]) => ( + + {value} + {unit} + + ))} +

); } - -function getTimeDifference(date1: Date, date2: Date) { - const diff = (date2.getTime() - date1.getTime()) / 1000; - let delta = Math.abs(diff); - - const days = Math.floor(delta / 86400); - delta -= days * 86400; - - const hours = Math.floor(delta / 3600) % 24; - delta -= hours * 3600; - - const minutes = Math.floor(delta / 60) % 60; - delta -= minutes * 60; - - const seconds = Math.floor(delta % 60); - - return { - diff, - days, - hours, - minutes, - seconds, - }; -} diff --git a/docs/src/app/(main)/pricing/_components/plan-tabs.tsx b/docs/src/app/(main)/pricing/_components/plan-tabs.tsx new file mode 100644 index 00000000..7e6dc5df --- /dev/null +++ b/docs/src/app/(main)/pricing/_components/plan-tabs.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Children, useId, useRef, useState, type ReactNode } from 'react'; + +export function PlanTabs(props: { + tabs: { name: string; price: string }[]; + defaultIndex: number; + children: ReactNode; +}) { + const { tabs, children } = props; + const [active, setActive] = useState(props.defaultIndex); + const buttons = useRef<(HTMLButtonElement | null)[]>([]); + const id = useId(); + + const select = (index: number) => { + const next = (index + tabs.length) % tabs.length; + setActive(next); + buttons.current[next]?.focus(); + }; + + return ( +
+
+
{ + if (event.key === 'ArrowRight') select(active + 1); + if (event.key === 'ArrowLeft') select(active - 1); + }} + > + {tabs.map((tab, index) => ( + + ))} +
+
+ {Children.toArray(children).map((panel, index) => ( + + ))} +
+ ); +} diff --git a/docs/src/app/(main)/pricing/_components/pricing.tsx b/docs/src/app/(main)/pricing/_components/pricing.tsx index 3b10a080..70731330 100644 --- a/docs/src/app/(main)/pricing/_components/pricing.tsx +++ b/docs/src/app/(main)/pricing/_components/pricing.tsx @@ -1,368 +1,389 @@ -import { ActionButton } from '@/components/action-button'; import { env } from '@/env'; -import { formatFileSize, formatNumber } from '@/lib/formatter'; +import { formatFileSize } from '@/lib/formatter'; import { cn } from '@/lib/utils'; -import { CheckCircleIcon, HeartIcon } from 'lucide-react'; -import { Fragment } from 'react'; +import { Check, Minus } from 'lucide-react'; +import Link from 'next/link'; +import { Fragment, type ReactNode } from 'react'; +import { + siteButton, + siteContainer, + sitePrimaryButton, + siteSecondaryButton, +} from '../../_components/styles'; import { CampaignCountdown } from './campaign-countdown'; +import { PlanTabs } from './plan-tabs'; export async function Pricing() { const pricingInfo = await fetchPricingInfo(); - const { defaultPricing, campaignPricing, upcomingPricing, campaign } = - pricingInfo; - - const pricingBlocks = buildPricingBlocks( - defaultPricing, - campaignPricing, - upcomingPricing, - ); + const plans = buildPlans(pricingInfo); + const features = buildFeatures(pricingInfo); return ( -
-
-
Pricing
-
- {"Choose the plan that's right for you."} +
+

+ Pricing +

+

+ Plans for EdgeStore hosted storage. The library is open source and free + with{' '} + + your own storage provider + + . +

+ {pricingInfo.campaign && ( + + )} + +
+
+ ({ + name: plan.name, + price: `$${plan.price}`, + }))} + defaultIndex={1} + > + {plans.map((plan) => ( +
+ + {features.map((group) => ( +
+

{group.title}

+
+ {group.rows.map((row) => ( +
+
{row.label}
+
+ {row.values[plan.id]} +
+
+ ))} +
+
+ ))} +
+ ))} +
-
- {campaign && ( -
- + +
+
+
+ Feature +
+ {plans.map((plan) => ( + + ))} +
+ {features.map((group) => ( + +
+
+ {group.title} +
+ {plans.map((plan) => ( +
+ ))} +
+ {group.rows.map((row) => ( +
+
+ {row.label} +
+ {plans.map((plan) => ( +
+ {row.values[plan.id]} +
+ ))} +
+ ))} + + ))}
- )} -
-
-
- - - - +
+ +
+
+

Custom

+

+ Higher limits, more projects and team members, and an hour of + one-on-one support each month. +

+ + Contact us +
+ +

+ Using EdgeStore with your own storage?{' '} + + Sponsor the project on GitHub + + . +

); } -function PricingBlock(props: { - item: PricingBlockItem; - isMain?: boolean; - className?: string; -}) { - const { - item: { id, title, description, price, upcoming, features, action }, - isMain, - className, - } = props; +const tableRow = 'grid grid-cols-4'; +const featuredColumn = + 'bg-[color-mix(in_srgb,var(--site-accent)_6%,var(--site-bg))]'; + +function PlanSummary(props: { plan: Plan; role?: string; className?: string }) { + const { plan, role, className } = props; return ( -
-
{title}
-
{description}
-
- {price === undefined ? ( - <> -
- contact:{' '} - - support@edgestore.dev - -
- - ) : price > 0 ? ( - <> -
${price}
-
/month
- - ) : ( -
- -
- )} -
- {upcoming && ( -
- {`$${upcoming.price}/month for new subscribers from ${formatDate(upcoming.activeFrom)}. Subscribe before then to keep $${price}.`} -
+
+

{plan.name}

+

+ {plan.description} +

+

+ + ${plan.price} + + /month +

+ {plan.upcoming && ( +

+ {`$${plan.upcoming.price}/month for new subscribers from ${formatDate(plan.upcoming.activeFrom)}. Subscribe before then to keep $${plan.price}.`} +

)} -
- {features.map((feature, i) => ( -
- -
{feature}
-
- ))} -
- {action ?? - (id ? ( - - Sign Up - - ) : ( - - Sign Up - - ))} + + {plan.cta} +
); } -function SponsorButton() { +function PlanLimit(props: { + value: number; + campaignValue?: number; + format?: (value: number) => string; +}) { + const { value, campaignValue, format = String } = props; + + if (campaignValue === undefined || campaignValue === value) { + return {format(value)}; + } return ( - - Sponsor - + + {format(value)} + {format(campaignValue)} + ); } -function Strikethrough(props: { - children: React.ReactNode; - size?: 'sm' | 'lg'; -}) { - const { children, size = 'sm' } = props; - return ( -
-
-
-
- {children} -
+function Included({ value }: { value: boolean }) { + return value ? ( + <> + + Included + + ) : ( + <> + + Not included + ); } -type PricingBlockItem = { - id?: string; - title: string; - description: string | React.ReactNode; - price?: number; +type Plan = { + id: PlanType; + name: string; + description: string; + price: number; upcoming?: { price: number; activeFrom: string }; - features: (string | React.ReactNode)[]; - action?: React.ReactNode; + href: string; + cta: string; }; -function buildPricingBlocks( - defaultPricing: PricingInfo['defaultPricing'], - campaignPricing: PricingInfo['campaignPricing'], - upcomingPricing: PricingInfo['upcomingPricing'], -) { +function buildPlans(info: PricingInfo): Plan[] { + const { defaultPricing, upcomingPricing } = info; + const dashboardUrl = env.NEXT_PUBLIC_DASHBOARD_URL; + return [ { - title: 'Free', - description: 'Best for individuals and non-commercial projects.', + id: 'FREE', + name: 'Free', + description: 'For personal and non-commercial projects.', price: 0, - features: [ - - {' '} - storage - , - - {' '} - projects - , - - Up to{' '} - {' '} - of bandwidth per month - , - - Up to{' '} - {' '} - of uploads per month - , - - {' '} - day of usage metrics - , - 'Discord/Github standard support', - 'Personal use', - ], + href: new URL('/sign-up', dashboardUrl).toString(), + cta: 'Start for free', }, { id: 'STARTER', - title: 'Starter', - description: - 'Best for start-ups and businesses who build commercial products with Edge Store.', + name: 'Starter', + description: 'For commercial apps and small teams.', price: toDollars(defaultPricing.STARTER.basePrice, 5), upcoming: toUpcoming(upcomingPricing?.STARTER), - features: [ - 'Everything in Free', - - {' '} - storage - , - - {' '} - team members - , - - {' '} - projects - , - - Up to{' '} - {' '} - of bandwidth per month - , - - Up to{' '} - {' '} - of uploads per month - , - - {' '} - day of usage metrics - , - 'Discord/Github priority support', - 'Email support', - 'Commercial use', - ], + href: `${dashboardUrl}/subscription/new?plan=STARTER`, + cta: 'Choose Starter', }, { id: 'PRO', - title: 'Pro', - description: - 'Best for businesses who build larger commercial products with Edge Store.', + name: 'Pro', + description: 'For apps with more files and traffic.', price: toDollars(defaultPricing.PRO.basePrice, 35), upcoming: toUpcoming(upcomingPricing?.PRO), - features: [ - 'Everything in Starter', - - {' '} - storage - , - - {' '} - team members - , - 'more members for $5/month each', - - {' '} - projects - , - 'more projects for $5/month each', - - Up to{' '} - {' '} - of bandwidth per month - , - - Up to{' '} - {' '} - of uploads per month - , - - {' '} - day of usage metrics - , - 'one-time 30min optional project consultation', + href: `${dashboardUrl}/subscription/new?plan=PRO`, + cta: 'Choose Pro', + }, + ]; +} + +type PlanValues = Record; + +type FeatureGroup = { + title: string; + rows: { label: string; values: PlanValues }[]; +}; + +function buildFeatures(info: PricingInfo): FeatureGroup[] { + const byPlan = (render: (plan: PlanType) => ReactNode): PlanValues => ({ + FREE: render('FREE'), + STARTER: render('STARTER'), + PRO: render('PRO'), + }); + const limit = (key: LimitKey, format?: (value: number) => string) => + byPlan((plan) => ( + + )); + const paidOnly = byPlan((plan) => ); + const withAddOn = (values: PlanValues): PlanValues => ({ + ...values, + PRO: ( + <> + {values.PRO} + + +$5/month each + + + ), + }); + + return [ + { + title: 'Usage', + rows: [ + { label: 'Storage', values: limit('storageLimit', formatFileSize) }, + { + label: 'Bandwidth per month', + values: limit('monthlyBandwidthLimit', formatFileSize), + }, + { + label: 'Uploads per month', + values: limit('monthlyUploadSizeLimit', formatFileSize), + }, ], }, { - title: 'Custom', - description: - "If the other plans don't fit your needs, we can create a custom plan for you.", - features: [ - 'Everything in Pro', - 'More storage', - 'More team members', - 'More projects', - 'Bigger limits', - '1 hour of individual support via voice, video or email per month', + title: 'Workspace', + rows: [ + { label: 'Projects', values: withAddOn(limit('projectLimit')) }, + { label: 'Team members', values: withAddOn(limit('memberLimit')) }, + { + label: 'Usage metrics history', + values: limit('aggRangeLimit', (days) => `${days} days`), + }, + { + label: 'Restore deleted files', + values: byPlan((plan) => ( + + )), + }, + { label: 'Commercial use', values: paidOnly }, ], - action: ( - - Contact us - - ), }, - ] as const satisfies PricingBlockItem[]; + { + title: 'Support', + rows: [ + { + label: 'Discord and GitHub', + values: byPlan((plan) => + plan === 'FREE' ? 'Community' : 'Priority', + ), + }, + { label: 'Email', values: paidOnly }, + { + label: 'Project consultation', + values: byPlan((plan) => + plan === 'PRO' ? ( + '30 minutes, one-time' + ) : ( + + ), + ), + }, + ], + }, + ]; } // `fallback` covers dashboards that don't return prices yet. @@ -384,33 +405,6 @@ function formatDate(value: string) { }); } -function PlanLimitItem(props: { - defaultValue: string | number; - campaignValue?: string | number; - formatter?: (value: string | number) => string; -}) { - const { defaultValue, campaignValue, formatter } = props; - - if (campaignValue === undefined || campaignValue === defaultValue) { - return ( - - {formatter?.(defaultValue) ?? defaultValue} - - ); - } else { - return ( - <> - - <>{formatter?.(defaultValue) ?? defaultValue} - {' '} - - {formatter?.(campaignValue) ?? campaignValue} - - - ); - } -} - async function fetchPricingInfo() { const response = await fetch(`${env.NEXT_PUBLIC_DASHBOARD_URL}/api/pricing`, { headers: { @@ -423,6 +417,14 @@ async function fetchPricingInfo() { export type PlanType = 'FREE' | 'STARTER' | 'PRO'; +type LimitKey = + | 'storageLimit' + | 'projectLimit' + | 'memberLimit' + | 'monthlyBandwidthLimit' + | 'monthlyUploadSizeLimit' + | 'aggRangeLimit'; + export type PlanPricing = { id: string; isDefault: number;