feat: more work on public project pricing plans

pull/720/head
Travis Fischer 2025-07-07 16:02:28 -07:00
rodzic 15a5fd94b3
commit 32e1d92a2a
6 zmienionych plików z 116 dodań i 62 usunięć

Wyświetl plik

@ -59,6 +59,7 @@
"next-themes": "catalog:", "next-themes": "catalog:",
"plur": "catalog:", "plur": "catalog:",
"posthog-js": "catalog:", "posthog-js": "catalog:",
"pretty-ms": "^9.2.0",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-infinite-scroll-hook": "catalog:", "react-infinite-scroll-hook": "catalog:",

Wyświetl plik

@ -575,7 +575,7 @@ function ProjectHeader({
<HeroButton <HeroButton
heroVariant='orange' heroVariant='orange'
className='justify-self-end' className='justify-self-end'
disabled={tab === 'pricing'} disabled={tab === 'pricing' && !!ctx?.isAuthenticated}
asChild={tab !== 'pricing'} asChild={tab !== 'pricing'}
> >
<Link <Link

Wyświetl plik

@ -47,7 +47,7 @@ export function ProjectPricingPlans({
const currentPricingIntervalPlans = const currentPricingIntervalPlans =
pricingPlansByInterval[pricingInterval] ?? [] pricingPlansByInterval[pricingInterval] ?? []
// TODO: add support for different pricing intervals // TODO: add support for different pricing intervals and switching between them
const numPricingPlans = currentPricingIntervalPlans.length || 1 const numPricingPlans = currentPricingIntervalPlans.length || 1
return ( return (

Wyświetl plik

@ -5,12 +5,21 @@ import type {
Project Project
} from '@agentic/platform-types' } from '@agentic/platform-types'
import humanNumber from 'human-number' import humanNumber from 'human-number'
import { Loader2Icon, PlusIcon } from 'lucide-react' import {
CornerDownRightIcon,
Loader2Icon,
PlusIcon,
ShieldCheckIcon,
ShieldMinusIcon
} from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import plur from 'plur' import plur from 'plur'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { pricingAmountToFixedString } from '@/lib/utils' import {
getRateLimitIntervalLabel,
pricingAmountToFixedString
} from '@/lib/utils'
// const intervalToLabelMap: Record<PricingInterval, string> = { // const intervalToLabelMap: Record<PricingInterval, string> = {
// day: 'daily', // day: 'daily',
@ -41,10 +50,10 @@ export function ProjectPricingPlan({
const requestsLineItem = lineItems.find( const requestsLineItem = lineItems.find(
(lineItem) => lineItem.slug === 'requests' (lineItem) => lineItem.slug === 'requests'
) )
const isFreePlan = plan.slug === 'free'
// TODO: rate-limits const deployment = project.lastPublishedDeployment
// const deployment = project.lastPublishedDeployment const requestsRateLimit = plan.rateLimit ?? deployment?.defaultRateLimit
// const requestsRateLimit = plan.rateLimit ?? deployment?.defaultRateLimit
// TODO: support custom line-items // TODO: support custom line-items
// const customLineItems = lineItems.find( // const customLineItems = lineItems.find(
@ -53,8 +62,7 @@ export function ProjectPricingPlan({
// TODO: support defaultAggregation // TODO: support defaultAggregation
// TODO: support trialPeriodDays // TODO: support trialPeriodDays
// TODO: highlight if any tools are disabled on this pricing plan
// TODO: add rate-limits and finesse free tier to not be so bare-bones
return ( return (
<div className='justify-self-center w-full grid grid-cols-1 rounded-[2rem] shadow-[inset_0_0_2px_1px_#ffffff4d] ring-1 ring-black/5 max-lg:mx-auto max-lg:w-full max-lg:max-w-md max-w-lg'> <div className='justify-self-center w-full grid grid-cols-1 rounded-[2rem] shadow-[inset_0_0_2px_1px_#ffffff4d] ring-1 ring-black/5 max-lg:mx-auto max-lg:w-full max-lg:max-w-md max-w-lg'>
@ -78,72 +86,66 @@ export function ProjectPricingPlan({
<div className='text-sm'>/ {interval}</div> <div className='text-sm'>/ {interval}</div>
</div> </div>
{requestsLineItem && plan.slug !== 'free' && ( {requestsLineItem && !isFreePlan && (
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<h4 className='text-sm/6 font-medium'>Requests:</h4> <h4 className='text-sm/6 font-medium'>Requests:</h4>
{requestsLineItem.billingScheme === 'per_unit' ? ( {requestsLineItem.billingScheme === 'per_unit' ? (
<div className='ml-2 flex flex-row items-center gap-2'> <div className='ml-4 flex flex-row items-center gap-2'>
<div className='text-xl font-semibold leading-none py-2'> <div className='text-xl font-semibold leading-none py-2'>
${pricingAmountToFixedString(requestsLineItem.unitAmount)} ${pricingAmountToFixedString(requestsLineItem.unitAmount)}
</div> </div>
<div className='text-sm'> <div className='text-sm'>
/{' '} / per{' '}
{requestsLineItem.transformQuantity {requestsLineItem.transformQuantity
? `${requestsLineItem.transformQuantity.divideBy} ${plur('request', requestsLineItem.transformQuantity.divideBy)}` ? `${requestsLineItem.transformQuantity.divideBy} ${plur('request', requestsLineItem.transformQuantity.divideBy)}`
: 'request'} : 'request'}
</div> </div>
</div> </div>
) : requestsLineItem.billingScheme === 'tiered' ? ( ) : requestsLineItem.billingScheme === 'tiered' ? (
<div className='ml-2 flex flex-col gap-2'> <div className='ml-4 flex flex-col gap-2'>
{requestsLineItem.tiers?.map((tier, index) => { {requestsLineItem.tiers?.map((tier, index) => {
const isFirst = index === 0 const isFirst = index === 0
// const isLast = index >= requestsLineItem.tiers!.length - 1
const hasUnitAmount = tier.unitAmount !== undefined const hasUnitAmount = tier.unitAmount !== undefined
// const hasFlatAmount = tier.flatAmount !== undefined
const isFree = hasUnitAmount const isFree = hasUnitAmount
? // TODO: are these two mutually exclusive? check in stripe ? // TODO: are these two mutually exclusive? check in stripe
tier.unitAmount === 0 tier.unitAmount === 0
: tier.flatAmount === 0 : tier.flatAmount === 0
// TODO: improve `inf` label const isTierInfinite = tier.upTo === 'inf'
const numLabel = const numLabel =
tier.upTo === 'inf' tier.upTo === 'inf'
? 'infinite requests' ? 'infinite requests'
: `${humanNumber(tier.upTo)} ${plur('request', tier.upTo)}` : `${humanNumber(tier.upTo)} ${plur('request', tier.upTo)}`
const price = `$${pricingAmountToFixedString(
hasUnitAmount ? tier.unitAmount! : tier.flatAmount!
)}${hasUnitAmount ? ' per request' : ''}`
const numDesc = isFree
? isFirst
? isTierInfinite
? `FREE for all requests per ${interval}`
: `FREE for the first ${numLabel} per ${interval}`
: isTierInfinite
? `FREE for all requests after that per ${interval}`
: `FREE for requests up to ${numLabel} per ${interval}`
: isFirst
? isTierInfinite
? `${price} per ${interval}`
: `${price} for the first ${numLabel} per ${interval}`
: isTierInfinite
? `${price} after that per ${interval}`
: `${price} up to ${numLabel} per ${interval}`
return ( return (
<div key={index} className=''> <div
{isFree ? ( key={index}
isFirst ? ( className='flex flex-row items-center gap-2 text-sm text-secondary-foreground/80'
<div> >
FREE for the first {numLabel} per {interval} <CornerDownRightIcon className='size-4' />
</div>
) : ( <span>{numDesc}</span>
<div>
$
{pricingAmountToFixedString(
hasUnitAmount
? tier.unitAmount!
: tier.flatAmount!
)}{' '}
{hasUnitAmount ? `per request ` : ''}up to{' '}
{numLabel}
</div>
)
) : (
<div>
$
{pricingAmountToFixedString(
hasUnitAmount
? tier.unitAmount!
: tier.flatAmount!
)}{' '}
{hasUnitAmount ? `per request ` : ''}up to{' '}
{numLabel}
</div>
)}
</div> </div>
) )
})} })}
@ -154,38 +156,49 @@ export function ProjectPricingPlan({
</div> </div>
)} )}
{isFreePlan && (
<p className='text-pretty text-sm text-secondary-foreground/80'>
Try before you buy. 100% free!
</p>
)}
{requestsRateLimit?.enabled && (
<div className='flex flex-row items-center gap-2 text-sm text-secondary-foreground/80'>
{isFreePlan ? (
<ShieldMinusIcon aria-hidden className='size-4' />
) : (
<ShieldCheckIcon aria-hidden className='size-4' />
)}
<span>
{isFreePlan ? 'Limited' : 'Rate-limited'} to{' '}
{requestsRateLimit.limit} requests per{' '}
{getRateLimitIntervalLabel(requestsRateLimit.interval)}
</span>
</div>
)}
{plan.features && ( {plan.features && (
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<h4 className='text-sm/6 font-medium'>Features:</h4> <h4 className='text-sm/6 font-medium'>Features:</h4>
<ul className='space-y-1'> <ul className='ml-4 flex flex-col gap-2 list-disc'>
{plan.features.map((feature, index) => ( {plan.features.map((feature, index) => (
<li <li
key={index} key={index}
className='group flex flex-row items-start gap-2 text-sm/6 data-[disabled]:text-gray-400' className='flex flex-row items-center gap-2 text-sm text-secondary-foreground/80'
> >
<span className='inline-flex h-6 items-center'> <PlusIcon aria-hidden className='size-4' />
<PlusIcon
aria-hidden='true'
className='size-4 fill-gray-400 group-data-[disabled]:fill-gray-300'
/>
</span>
{feature} <span>{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
{plan.slug === 'free' && (
<p className='text-pretty text-xs/5 text-gray-400'>
Try before you buy. 100% free!
</p>
)}
{requestsLineItem?.billingScheme === 'tiered' && ( {requestsLineItem?.billingScheme === 'tiered' && (
<p className='text-pretty text-xs/5 text-gray-400'> <p className='text-pretty text-xs/5 text-muted-foreground'>
{requestsLineItem.tiersMode === 'graduated' ? ( {requestsLineItem.tiersMode === 'graduated' ? (
<> <>
Requests pricing tiers use{' '} Requests pricing tiers use{' '}

Wyświetl plik

@ -1,4 +1,5 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import prettyMs from 'pretty-ms'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
export { default as humanNumber } from 'human-number' export { default as humanNumber } from 'human-number'
@ -28,3 +29,39 @@ export function pricingAmountToFixedString(amount: number): string {
return output return output
} }
export function getRateLimitIntervalLabel(rateLimitInterval: number): string {
const label = prettyMs(rateLimitInterval * 1000, {
verbose: true
})
if (label === '1 second') {
return 'second'
}
if (label === '1 minute') {
return 'minute'
}
if (label === '1 hour') {
return 'hour'
}
if (label === '1 day') {
return 'day'
}
if (label === '1 week') {
return 'week'
}
if (label === '1 month') {
return 'month'
}
if (label === '1 year') {
return 'year'
}
return label
}

Wyświetl plik

@ -890,6 +890,9 @@ importers:
posthog-js: posthog-js:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.255.0 version: 1.255.0
pretty-ms:
specifier: ^9.2.0
version: 9.2.0
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.1.0 version: 19.1.0