kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat(web): wip add dynamic pricing plan component impl
rodzic
52691571d8
commit
c88bc60945
|
@ -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:",
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
|
|
Ładowanie…
Reference in New Issue