feat(web): wip add dynamic pricing plan component impl

pull/720/head
Travis Fischer 2025-07-04 23:59:10 -05:00
rodzic 52691571d8
commit c88bc60945
5 zmienionych plików z 199 dodań i 9 usunięć

Wyświetl plik

@ -50,6 +50,7 @@
"clsx": "catalog:",
"date-fns": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"human-number": "^2.0.4",
"ky": "catalog:",
"lucide-react": "catalog:",
"motion": "catalog:",
@ -75,6 +76,7 @@
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@tailwindcss/typography": "catalog:",
"@types/human-number": "^1.0.2",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/three": "catalog:",

Wyświetl plik

@ -1,9 +1,24 @@
import type { Consumer, PricingPlan, Project } from '@agentic/platform-types'
import { Loader2Icon } from 'lucide-react'
import type {
Consumer,
// PricingInterval,
PricingPlan,
Project
} from '@agentic/platform-types'
import humanNumber from 'human-number'
import { Loader2Icon, PlusIcon } from 'lucide-react'
import plur from 'plur'
import { Button } from '@/components/ui/button'
// const intervalToLabelMap: Record<PricingInterval, string> = {
// day: 'daily',
// week: 'weekly',
// month: 'monthly',
// year: 'yearly'
// }
export function ProjectPricingPlan({
project,
plan,
consumer,
isLoadingStripeCheckoutForPlan,
@ -16,15 +31,160 @@ export function ProjectPricingPlan({
onSubscribe: (planSlug: string) => void
className?: string
}) {
const { defaultPricingInterval } = project
const { lineItems } = plan
const interval = plan.interval ?? defaultPricingInterval
// const intervalLabel = intervalToLabelMap[interval]
const baseLineItem = lineItems.find((lineItem) => lineItem.slug === 'base')
const requestsLineItem = lineItems.find(
(lineItem) => lineItem.slug === 'requests'
)
// TODO
// const customLineItems = lineItems.find(
// (lineItem) => lineItem.slug !== 'base' && lineItem.slug !== 'requests'
// )
// TODO: support transformQuantity.divideBy
// TODO: support defaultAggregation
// TODO: toFixed(2) doesn't work for usage-based pricing with small amounts
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='grid grid-cols-1 rounded-[2rem] p-2 shadow-md shadow-black/5'>
<div className='rounded-3xl bg-card p-4 shadow-2xl ring-1 ring-black/5 flex flex-col gap-4 color-card-foreground'>
<h3 className='text-center text-balance leading-snug md:leading-none text-xl font-semibold'>
{plan.name} <span className='sr-only'>Plan</span>
</h3>
<div className='rounded-3xl bg-card p-4 shadow-2xl ring-1 ring-black/5 flex flex-col gap-4 color-card-foreground justify-between'>
<div className='flex flex-col gap-4'>
<h3 className='text-center text-balance leading-snug md:leading-none text-xl font-semibold'>
{plan.name} <span className='sr-only'>Plan</span>
</h3>
<pre className='max-w-lg'>{JSON.stringify(plan, null, 2)}</pre>
{plan.description && <p>{plan.description}</p>}
<div className='flex flex-row items-center gap-2'>
<div className='text-4xl font-semibold text-gray-950 leading-none py-2'>
${baseLineItem ? (baseLineItem.amount / 100).toFixed(2) : 0}
</div>
<div className='text-sm text-gray-600'>/ {interval}</div>
</div>
{requestsLineItem && plan.slug !== 'free' && (
<div className='flex flex-col gap-2'>
<h4 className='text-sm/6 font-medium text-gray-950'>
Requests:
</h4>
{requestsLineItem.billingScheme === 'per_unit' ? (
<div className='flex flex-row items-center gap-2'>
<div className='text-xl font-semibold text-gray-950 leading-none py-2'>
$
{requestsLineItem.unitAmount <= 0
? 0
: (requestsLineItem.unitAmount / 100).toFixed(2)}
</div>
<div className='text-sm text-gray-600'>
/{' '}
{requestsLineItem.transformQuantity
? `${requestsLineItem.transformQuantity.divideBy} ${plur('request', requestsLineItem.transformQuantity.divideBy)}`
: 'request'}
</div>
</div>
) : requestsLineItem.billingScheme === 'tiered' ? (
requestsLineItem.tiersMode === 'graduated' ? (
<div>TODO</div>
) : requestsLineItem.tiersMode === 'volume' ? (
<div>
{requestsLineItem.tiers?.map((tier, index) => {
const isFirst = index === 0
// const isLast = index >= requestsLineItem.tiers!.length - 1
const hasUnitAmount = tier.unitAmount !== undefined
// const hasFlatAmount = tier.flatAmount !== undefined
const isFree = hasUnitAmount
? // TODO: are these two mutually exclusive? check in stripe
tier.unitAmount === 0
: tier.flatAmount === 0
// TODO: improve `inf` label
const numLabel =
tier.upTo === 'inf'
? 'infinite requests'
: `${humanNumber(tier.upTo)} ${plur('request', tier.upTo)}`
return (
<div key={index} className=''>
{isFree ? (
isFirst ? (
<div>FREE for the first {numLabel}</div>
) : (
<div>
$
{hasUnitAmount
? (tier.unitAmount! / 100)
.toFixed(10)
.replace(/0+$/, '')
: (tier.flatAmount! / 100)
.toFixed(10)
.replace(/0+$/, '')}{' '}
{hasUnitAmount ? `per request ` : ''}up to{' '}
{numLabel}
</div>
)
) : (
<div>
$
{hasUnitAmount
? (tier.unitAmount! / 100)
.toFixed(10)
.replace(/0+$/, '')
: (tier.flatAmount! / 100)
.toFixed(10)
.replace(/0+$/, '')}{' '}
{hasUnitAmount ? `per request ` : ''}up to{' '}
{numLabel}
</div>
)}
</div>
)
})}
</div>
) : (
<div>
Unsupported pricing config. Please contact support.
</div>
)
) : (
<div>Unsupported pricing config. Please contact support.</div>
)}
</div>
)}
{plan.features && (
<div className='flex flex-col gap-2'>
<h4 className='text-sm/6 font-medium text-gray-950'>
Features:
</h4>
<ul className='space-y-1'>
{plan.features.map((feature, index) => (
<li
key={index}
className='group flex flex-row items-start gap-3 text-sm/6 text-gray-600 data-[disabled]:text-gray-400'
>
<span className='inline-flex h-6 items-center'>
<PlusIcon
aria-hidden='true'
className='size-4 fill-gray-400 group-data-[disabled]:fill-gray-300'
/>
</span>
{feature}
</li>
))}
</ul>
</div>
)}
</div>
<Button
onClick={() => onSubscribe(plan.slug)}

Wyświetl plik

@ -1,6 +1,8 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export { default as humanNumber } from 'human-number'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

Wyświetl plik

@ -246,8 +246,9 @@ export const pricingPlanMeteredLineItemSchema =
/**
* Optionally apply a transformation to the reported usage or set
* quantity before computing the amount billed. Cannot be combined
* with `tiers`.
* quantity before computing the amount billed.
*
* Cannot be combined with `tiers`.
*/
transformQuantity: z
.object({

Wyświetl plik

@ -866,6 +866,9 @@ importers:
hast-util-to-jsx-runtime:
specifier: 'catalog:'
version: 2.3.6
human-number:
specifier: ^2.0.4
version: 2.0.4
ky:
specifier: 'catalog:'
version: 1.8.1
@ -936,6 +939,9 @@ importers:
'@tailwindcss/typography':
specifier: 'catalog:'
version: 0.5.16(tailwindcss@4.1.11)
'@types/human-number':
specifier: ^1.0.2
version: 1.0.2
'@types/react':
specifier: 'catalog:'
version: 19.1.8
@ -6031,6 +6037,9 @@ packages:
'@types/http-cache-semantics@4.0.4':
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
'@types/human-number@1.0.2':
resolution: {integrity: sha512-Xm2hW0MXOjX/IOrRv8IRLycwVz23cU51b3fUn3Km5q8+4oM88uJaZ36dD1S0qINg92jhfQm7iEuAxZefIIffYg==}
'@types/js-cookie@2.2.7':
resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==}
@ -8381,6 +8390,10 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-number@2.0.4:
resolution: {integrity: sha512-OENvA941poJU1VGR6s5Nf/GpYNPE+81lmHkIVLO9FgiyHxB+BSlVOJV3lnItk5tfHzcEbZv3kTQrzpZK0+ExRA==}
engines: {node: '>= 8'}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@ -10625,6 +10638,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
round-to@5.0.0:
resolution: {integrity: sha512-i4+Ntwmo5kY7UWWFSDEVN3RjT2PX1FqkZ9iCcAO3sKML3Ady9NgsjM/HLdYKUAnrxK4IlSvXzpBMDvMHZQALRQ==}
engines: {node: '>=10'}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@ -17539,6 +17556,8 @@ snapshots:
'@types/http-cache-semantics@4.0.4': {}
'@types/human-number@1.0.2': {}
'@types/js-cookie@2.2.7': {}
'@types/json-schema@7.0.15': {}
@ -20304,6 +20323,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-number@2.0.4:
dependencies:
round-to: 5.0.0
human-signals@2.1.0: {}
human-signals@8.0.1: {}
@ -22948,6 +22971,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.44.1
fsevents: 2.3.3
round-to@5.0.0: {}
router@2.2.0:
dependencies:
debug: 4.4.1(supports-color@10.0.0)