chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { ChipSwitch, ChipTag } from '@sim/emcn'
|
||||
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
|
||||
|
||||
/**
|
||||
* Props for {@link BillingPeriodToggle}.
|
||||
*/
|
||||
export interface BillingPeriodToggleProps {
|
||||
/** Whether the annual billing period is currently selected. */
|
||||
isAnnual: boolean
|
||||
/** Invoked with the next selection when a segment is clicked. */
|
||||
onChange: (isAnnual: boolean) => void
|
||||
/** Optional additional classes merged onto the container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discount label derived from the real billing constant so it stays in sync if
|
||||
* the rate changes (e.g. `0.15` renders as `-15%`).
|
||||
*/
|
||||
const DISCOUNT_LABEL = `-${Math.round(ANNUAL_DISCOUNT_RATE * 100)}%`
|
||||
|
||||
type Period = 'annual' | 'monthly'
|
||||
|
||||
/**
|
||||
* Annual / monthly billing-period segmented switch. Built on {@link ChipSwitch}
|
||||
* with an inline brand-tinted discount badge on the annual segment.
|
||||
*/
|
||||
export function BillingPeriodToggle({ isAnnual, onChange, className }: BillingPeriodToggleProps) {
|
||||
return (
|
||||
<ChipSwitch<Period>
|
||||
aria-label='Billing period'
|
||||
value={isAnnual ? 'annual' : 'monthly'}
|
||||
onChange={(next) => onChange(next === 'annual')}
|
||||
className={className}
|
||||
options={[
|
||||
{
|
||||
value: 'annual',
|
||||
label: (
|
||||
<>
|
||||
Annual
|
||||
<ChipTag variant='mono' className={isAnnual ? undefined : 'text-inherit'}>
|
||||
{DISCOUNT_LABEL}
|
||||
</ChipTag>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BillingPeriodToggle, type BillingPeriodToggleProps } from './billing-period-toggle'
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
CREDIT_TIERS,
|
||||
CREDITS_PER_DOLLAR,
|
||||
DAILY_REFRESH_RATE,
|
||||
DEFAULT_FREE_CREDITS,
|
||||
} from '@/lib/billing/constants'
|
||||
|
||||
/**
|
||||
* Looks up a credit tier by name, so a column binds to the right tier even if
|
||||
* {@link CREDIT_TIERS} is reordered or a tier is inserted.
|
||||
*/
|
||||
function tierByName(name: (typeof CREDIT_TIERS)[number]['name']) {
|
||||
const tier = CREDIT_TIERS.find((t) => t.name === name)
|
||||
if (!tier) throw new Error(`comparison-data: no credit tier named "${name}"`)
|
||||
return tier
|
||||
}
|
||||
|
||||
const PRO_TIER = tierByName('Pro')
|
||||
const MAX_TIER = tierByName('Max')
|
||||
|
||||
/** Formats a credit count with thousands separators (e.g. 25000 → "25,000"). */
|
||||
const formatCredits = (credits: number): string => credits.toLocaleString('en-US')
|
||||
|
||||
/** Daily refresh credits for a plan: 1% of plan dollars/day, in credits. */
|
||||
const dailyRefreshCredits = (dollars: number): number =>
|
||||
Math.round(dollars * DAILY_REFRESH_RATE * CREDITS_PER_DOLLAR)
|
||||
|
||||
/** A brand icon rendered in a cell instead of a check/em-dash/text. */
|
||||
export interface CellIcon {
|
||||
/** Icon identifier resolved to a component by the table renderer. */
|
||||
icon: 'slack'
|
||||
}
|
||||
|
||||
/** Cell value for the comparison table. */
|
||||
export type CellValue = string | boolean | CellIcon
|
||||
|
||||
/** Shared Slack-availability cell. */
|
||||
const SLACK: CellIcon = { icon: 'slack' }
|
||||
|
||||
/** Names of the four plan columns — used as a discriminated union for type-safe plan selection. */
|
||||
export type PlanName = 'Free' | 'Pro' | 'Max' | 'Enterprise'
|
||||
|
||||
/** A single feature row inside a section. */
|
||||
export interface ComparisonRow {
|
||||
/** Row label displayed in the left column. */
|
||||
label: string
|
||||
/**
|
||||
* Values for [Free, Pro, Max, Enterprise].
|
||||
* `true` renders a check icon; `false` renders a muted em-dash.
|
||||
* Strings render as-is with tabular-nums styling.
|
||||
*/
|
||||
values: [CellValue, CellValue, CellValue, CellValue]
|
||||
}
|
||||
|
||||
/** A labelled group of comparison rows. */
|
||||
export interface ComparisonSection {
|
||||
/** Section header label. */
|
||||
title: string
|
||||
rows: ComparisonRow[]
|
||||
}
|
||||
|
||||
/** Column metadata for the four plan headers. */
|
||||
export interface PlanColumn {
|
||||
/** Plan name — matches the {@link PlanName} discriminant. */
|
||||
name: PlanName
|
||||
/**
|
||||
* Price display. Pass `null` to signal "use runtime price from state"
|
||||
* (handled in the component for Pro and Max).
|
||||
*/
|
||||
staticPrice: string | null
|
||||
}
|
||||
|
||||
/** Ordered plan columns — indices match `ComparisonRow.values`. */
|
||||
export const PLAN_COLUMNS: PlanColumn[] = [
|
||||
{ name: 'Free', staticPrice: '$0' },
|
||||
{ name: 'Pro', staticPrice: null },
|
||||
{ name: 'Max', staticPrice: null },
|
||||
{ name: 'Enterprise', staticPrice: 'Custom' },
|
||||
]
|
||||
|
||||
/** Full comparison dataset. */
|
||||
export const COMPARISON_SECTIONS: ComparisonSection[] = [
|
||||
{
|
||||
title: 'Credits & pricing',
|
||||
rows: [
|
||||
{
|
||||
label: 'Monthly credits',
|
||||
values: [
|
||||
formatCredits(DEFAULT_FREE_CREDITS * CREDITS_PER_DOLLAR),
|
||||
formatCredits(PRO_TIER.credits),
|
||||
formatCredits(MAX_TIER.credits),
|
||||
'Custom',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Daily refresh',
|
||||
values: [
|
||||
false,
|
||||
`+${formatCredits(dailyRefreshCredits(PRO_TIER.dollars))}`,
|
||||
`+${formatCredits(dailyRefreshCredits(MAX_TIER.dollars))}`,
|
||||
'Custom',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workspaces & teams',
|
||||
rows: [
|
||||
{
|
||||
label: 'Workspaces',
|
||||
values: ['1', '3', 'Unlimited', 'Unlimited'],
|
||||
},
|
||||
{
|
||||
label: 'Invite teammates',
|
||||
values: [false, true, true, true],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Rate limits (runs/min)',
|
||||
rows: [
|
||||
{
|
||||
label: 'Sync executions',
|
||||
values: ['50', '150', '300', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'Async executions',
|
||||
values: ['200', '1,000', '2,500', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'API endpoint',
|
||||
values: ['0', '100', '200', 'Custom'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Execution timeouts',
|
||||
rows: [
|
||||
{
|
||||
label: 'Sync timeout',
|
||||
values: ['5 min', '50 min', '50 min', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'Async timeout',
|
||||
values: ['90 min', '90 min', '90 min', 'Custom'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Storage & data',
|
||||
rows: [
|
||||
{
|
||||
label: 'File storage',
|
||||
values: ['5 GB', '50 GB', '500 GB', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'Max tables',
|
||||
values: ['5', '100', '1,000', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'Max rows per table',
|
||||
values: ['50,000', '100,000', '500,000', 'Custom'],
|
||||
},
|
||||
{
|
||||
label: 'Log retention',
|
||||
values: ['30 days', 'Unlimited', 'Unlimited', 'Unlimited'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Features',
|
||||
rows: [
|
||||
{
|
||||
label: 'Sim Mailer (Inbox)',
|
||||
values: [false, false, true, true],
|
||||
},
|
||||
{
|
||||
label: 'KB Live Sync',
|
||||
values: [false, false, true, true],
|
||||
},
|
||||
{
|
||||
label: 'Slack Connect',
|
||||
values: [false, false, SLACK, SLACK],
|
||||
},
|
||||
{
|
||||
label: 'Access Control',
|
||||
values: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
label: 'SSO',
|
||||
values: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
label: 'SOC2 Compliance',
|
||||
values: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
label: 'Self Hosting',
|
||||
values: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
label: 'Dedicated Support',
|
||||
values: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
import { chipVariants, cn } from '@sim/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { BillingPeriodToggle } from '@/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle'
|
||||
import {
|
||||
type CellValue,
|
||||
COMPARISON_SECTIONS,
|
||||
PLAN_COLUMNS,
|
||||
type PlanName,
|
||||
} from '@/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data'
|
||||
|
||||
/** Maps a cell-icon identifier to its brand icon component. */
|
||||
const CELL_ICONS = { slack: SlackIcon } as const
|
||||
|
||||
/**
|
||||
* Resolved CTA for a plan column, mirroring the upgrade-page plan cards so the
|
||||
* table and cards stay in lockstep (same label, variant, and disabled state).
|
||||
*/
|
||||
export interface ComparisonPlanCta {
|
||||
label: string
|
||||
variant: 'primary' | 'border-shadow'
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link ComparisonTable}.
|
||||
*/
|
||||
export interface ComparisonTableProps {
|
||||
/**
|
||||
* Resolved Pro price string, e.g. `"$29"`.
|
||||
* Sourced from the page-level `proPrice` derived from `useUpgradeState`.
|
||||
*/
|
||||
proPrice: string
|
||||
/**
|
||||
* Resolved Max price string, e.g. `"$79"`.
|
||||
* Sourced from the page-level `maxPrice` derived from `useUpgradeState`.
|
||||
*/
|
||||
maxPrice: string
|
||||
/**
|
||||
* Whether annual billing is currently selected.
|
||||
* Shared with the page-level {@link BillingPeriodToggle} via a single state source.
|
||||
*/
|
||||
isAnnual: boolean
|
||||
/**
|
||||
* Invoked when the in-table billing toggle changes.
|
||||
* Should point to the same setter as the page-level toggle.
|
||||
*/
|
||||
onIsAnnualChange: (isAnnual: boolean) => void
|
||||
/**
|
||||
* Resolved CTA per plan column, mirroring the upgrade-page plan cards. Plans
|
||||
* without an entry (e.g. Free) render no button.
|
||||
*/
|
||||
ctas: Partial<Record<PlanName, ComparisonPlanCta>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline check icon — matches the card-level `CheckIcon` shape.
|
||||
*/
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 14 14'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
className='size-[14px] flex-shrink-0'
|
||||
>
|
||||
<path
|
||||
d='M2.5 7L5.5 10L11.5 4'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single cell value: `true` → check icon, `false` → em-dash, string → text.
|
||||
*/
|
||||
function Cell({ value }: { value: CellValue }) {
|
||||
if (value === true) {
|
||||
return (
|
||||
<span className='flex justify-center text-[var(--text-primary)]'>
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (value === false) {
|
||||
return (
|
||||
<span className='flex select-none justify-center text-[var(--text-muted)] text-base'>—</span>
|
||||
)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const Icon = CELL_ICONS[value.icon]
|
||||
return (
|
||||
<span className='flex justify-center'>
|
||||
<Icon className='size-[14px] flex-shrink-0' />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className='block text-center text-[var(--text-primary)] text-small tabular-nums'>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full plan-comparison table. Renders all sections from {@link COMPARISON_SECTIONS}
|
||||
* mapped over generically — no per-cell copy-paste. Prices for Pro and Max are
|
||||
* supplied as props so they stay in sync with the billing-period toggle in the
|
||||
* parent page.
|
||||
*
|
||||
* The top-left cell contains a "Compare plans" heading, a subtitle, and a
|
||||
* {@link BillingPeriodToggle} that shares state with the page-level toggle via
|
||||
* the `isAnnual` / `onIsAnnualChange` props.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ComparisonTable
|
||||
* proPrice={`$${proPrice}`}
|
||||
* maxPrice={`$${maxPrice}`}
|
||||
* isAnnual={state.isAnnual}
|
||||
* onIsAnnualChange={state.setIsAnnual}
|
||||
* ctas={{ Pro: proCta, Max: maxCta, Enterprise: enterpriseCta }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ComparisonTable({
|
||||
proPrice,
|
||||
maxPrice,
|
||||
isAnnual,
|
||||
onIsAnnualChange,
|
||||
ctas,
|
||||
}: ComparisonTableProps) {
|
||||
const runtimePrices: Partial<Record<PlanName, string>> = {
|
||||
Pro: proPrice,
|
||||
Max: maxPrice,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-auto rounded-xl border border-[var(--border-1)]'>
|
||||
{/* CSS grid: 1 label col + 4 equal plan cols */}
|
||||
<div className='grid min-w-[640px] grid-cols-[1fr_repeat(4,minmax(0,1fr))]'>
|
||||
{/* ── Column headers ── */}
|
||||
{/* Top-left cell: title, subtitle, and billing toggle */}
|
||||
<div className='flex h-full flex-col justify-between gap-3 border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-4'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-base'>Compare plans</span>
|
||||
<span className='text-[var(--text-muted)] text-small'>Find the right plan for you</span>
|
||||
</div>
|
||||
<BillingPeriodToggle isAnnual={isAnnual} onChange={onIsAnnualChange} />
|
||||
</div>
|
||||
|
||||
{PLAN_COLUMNS.map((col) => {
|
||||
const price = runtimePrices[col.name] ?? col.staticPrice ?? ''
|
||||
const cta = ctas[col.name]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className='flex flex-col items-center gap-1 bg-[var(--surface-2)] px-3 py-4 text-center'
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-base'>{col.name}</span>
|
||||
<span className='font-medium text-[var(--text-primary)] text-md tabular-nums'>
|
||||
{price}
|
||||
</span>
|
||||
{cta && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={cta.onClick}
|
||||
disabled={cta.disabled}
|
||||
aria-label={`${cta.label} — ${col.name}`}
|
||||
className={cn(
|
||||
chipVariants({ variant: cta.variant, fullWidth: true, flush: true }),
|
||||
'mt-2 w-full justify-center'
|
||||
)}
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Sections ── */}
|
||||
{COMPARISON_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.title} className='contents'>
|
||||
{/* Section header row — split so the left-column separator stays continuous */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-4 bg-[var(--surface-2)]',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Feature rows */}
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<div key={row.label} className='contents'>
|
||||
{/* Label */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2.5',
|
||||
rowIdx < section.rows.length - 1 && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='text-[var(--text-body)] text-small'>{row.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Plan cells */}
|
||||
{row.values.map((value, colIdx) => (
|
||||
<div
|
||||
key={PLAN_COLUMNS[colIdx].name}
|
||||
className={cn(
|
||||
'flex items-center justify-center bg-[var(--surface-2)] px-3 py-2.5',
|
||||
rowIdx < section.rows.length - 1 && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<Cell value={value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CellValue, ComparisonRow, ComparisonSection, PlanColumn } from './comparison-data'
|
||||
export { ComparisonTable, type ComparisonTableProps } from './comparison-table'
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BillingPeriodToggle, type BillingPeriodToggleProps } from './billing-period-toggle'
|
||||
export { ComparisonTable, type ComparisonTableProps } from './comparison-table'
|
||||
export type { PlanName } from './comparison-table/comparison-data'
|
||||
export { UpgradePlanCard, type UpgradePlanCardProps } from './plan-card'
|
||||
@@ -0,0 +1 @@
|
||||
export { UpgradePlanCard, type UpgradePlanCardProps } from './plan-card'
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
import { Check, ChipTag, Credit, chipVariants, cn, Info, RefreshCw } from '@sim/emcn'
|
||||
|
||||
/**
|
||||
* Props for {@link UpgradePlanCard}.
|
||||
*/
|
||||
export interface UpgradePlanCardProps {
|
||||
/** Plan name, e.g. `"Pro"`. */
|
||||
name: string
|
||||
/** Headline price, e.g. `"$29"` or `"Custom"`. */
|
||||
price: string
|
||||
/** Small line under the price, e.g. `"per user/month, billed annually"`. */
|
||||
priceSubtext?: string
|
||||
/** Optional discount pill rendered next to the price, e.g. `"20% off"`. */
|
||||
discountLabel?: string
|
||||
/** Short description below the plan name, e.g. `"For growing teams"`. */
|
||||
segmentLabel: string
|
||||
/**
|
||||
* Monthly credit allocation shown prominently under the CTA, e.g. `"6,000 credits/mo"` or `"Custom"`.
|
||||
* When omitted the credits/refresh block is not rendered.
|
||||
*/
|
||||
credits?: string
|
||||
/**
|
||||
* Daily refresh allocation shown below the credit amount, e.g. `"+50/day refresh"`.
|
||||
* Only rendered when {@link UpgradePlanCardProps.credits} is also set.
|
||||
*/
|
||||
refresh?: string
|
||||
/** Feature bullet list — each row renders with a check icon. */
|
||||
features: readonly string[]
|
||||
/** CTA label. */
|
||||
buttonText: string
|
||||
/** CTA click handler. */
|
||||
onButtonClick: () => void
|
||||
/** Whether the CTA is disabled. */
|
||||
buttonDisabled?: boolean
|
||||
/** When set, the CTA renders with the primary chip variant. */
|
||||
highlighted?: boolean
|
||||
/** Optional pill rendered in the top-right corner, e.g. `"Your plan"`. */
|
||||
bannerText?: string
|
||||
/** Extra outer classes. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical pricing card styled with workspace surface, border, and text tokens.
|
||||
* CTA reuses {@link chipVariants} so it picks up the platform's standard 30px
|
||||
* pill chrome (primary filled when highlighted, neutral filled otherwise).
|
||||
*
|
||||
* When `credits` is supplied, a prominent stats block using the same `Credit`
|
||||
* icon as the home-page chip is rendered directly below the CTA, followed by a
|
||||
* solid `1px` divider (matching the integrations/skills section separator) before
|
||||
* the bullet feature list. Pass `credits` on every card tier, including Enterprise
|
||||
* (which uses `"Custom"` for both `credits` and `refresh`).
|
||||
*/
|
||||
export function UpgradePlanCard({
|
||||
name,
|
||||
price,
|
||||
priceSubtext,
|
||||
discountLabel,
|
||||
segmentLabel,
|
||||
credits,
|
||||
refresh,
|
||||
features,
|
||||
buttonText,
|
||||
onButtonClick,
|
||||
buttonDisabled,
|
||||
highlighted,
|
||||
bannerText,
|
||||
className,
|
||||
}: UpgradePlanCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'flex h-full flex-col gap-4 rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] p-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<h3 className='font-medium text-[24px] text-[var(--text-primary)]'>{name}</h3>
|
||||
{bannerText && <ChipTag variant='gray'>{bannerText}</ChipTag>}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-[20px] text-[var(--text-primary)] tabular-nums'>
|
||||
{price}
|
||||
</span>
|
||||
{discountLabel && <ChipTag variant='mono'>{discountLabel}</ChipTag>}
|
||||
</div>
|
||||
<p className='text-[var(--text-muted)] text-base'>{priceSubtext ?? '\u00A0'}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={onButtonClick}
|
||||
disabled={buttonDisabled}
|
||||
className={cn(
|
||||
chipVariants({
|
||||
variant: highlighted ? 'primary' : 'border-shadow',
|
||||
fullWidth: true,
|
||||
flush: true,
|
||||
}),
|
||||
'w-full justify-center'
|
||||
)}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
|
||||
{/* Credits + refresh stats block — omitted on plans without a fixed credit amount */}
|
||||
{credits && (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Credit className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='text-[var(--text-body)] text-sm'>{credits}</span>
|
||||
<Info>1 workflow run = 1 credit. Inference usage consumes credits separately.</Info>
|
||||
</div>
|
||||
{refresh && (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<RefreshCw className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='text-[var(--text-body)] text-sm'>{refresh}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section header + divider matching integrations/skills separator language */}
|
||||
<div className='flex flex-col'>
|
||||
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{segmentLabel}</span>
|
||||
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2'>
|
||||
<Check className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='text-[var(--text-body)] text-sm'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user