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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type UpgradeState, useUpgradeState } from './use-upgrade-state'
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import { CREDIT_TIERS } from '@/lib/billing/constants'
|
||||
import {
|
||||
getPlanTierCredits,
|
||||
isEnterprise,
|
||||
isFree,
|
||||
isPaid,
|
||||
isPro,
|
||||
isTeam,
|
||||
} from '@/lib/billing/plan-helpers'
|
||||
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
||||
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/upgrade/subscription-permissions'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const PRO_TIER = CREDIT_TIERS[0]
|
||||
const MAX_TIER = CREDIT_TIERS[1]
|
||||
|
||||
type TargetPlan = 'pro' | 'team'
|
||||
|
||||
export interface UpgradeState {
|
||||
isLoading: boolean
|
||||
isAnnual: boolean
|
||||
setIsAnnual: (v: boolean) => void
|
||||
subscription: {
|
||||
isFree: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
isPaid: boolean
|
||||
isOrgScoped: boolean
|
||||
plan: string
|
||||
status: string
|
||||
}
|
||||
showUpgradePlans: boolean
|
||||
proTier: { credits: number; dollars: number; name: string }
|
||||
maxTier: { credits: number; dollars: number; name: string }
|
||||
isOnPro: boolean
|
||||
isOnMax: boolean
|
||||
isOnMaxTier: boolean
|
||||
wantsIntervalSwitch: boolean
|
||||
doUpgrade: (targetPlan: 'pro' | 'team', creditTier: number, seats?: number) => Promise<void>
|
||||
handleSwitchInterval: (interval: 'month' | 'year') => Promise<void>
|
||||
upgradeOrSwitchToMax: () => Promise<void>
|
||||
onUpgradeToOtherTier: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan-selection state hook for the Upgrade page. Surfaces only what the plan
|
||||
* cards and billing-period toggle need: the resolved tier, upgrade/downgrade/
|
||||
* interval-switch handlers, and whether to show the upgrade plans at all.
|
||||
*
|
||||
* Plan and billing management (payment method, cancellation, invoices, usage
|
||||
* limits) lives on the Billing settings page, not here.
|
||||
*/
|
||||
export function useUpgradeState(): UpgradeState {
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
|
||||
const {
|
||||
data: subscriptionData,
|
||||
isLoading,
|
||||
refetch: refetchSubscription,
|
||||
} = useSubscriptionData({ includeOrg: true })
|
||||
|
||||
const [isAnnual, setIsAnnual] = useState(true)
|
||||
const hasInitializedInterval = useRef(false)
|
||||
|
||||
const subscription = {
|
||||
isFree: isFree(subscriptionData?.data?.plan),
|
||||
isPro: isPro(subscriptionData?.data?.plan),
|
||||
isTeam: isTeam(subscriptionData?.data?.plan),
|
||||
isEnterprise: isEnterprise(subscriptionData?.data?.plan),
|
||||
isPaid:
|
||||
isPaid(subscriptionData?.data?.plan) &&
|
||||
hasPaidSubscriptionStatus(subscriptionData?.data?.status),
|
||||
/**
|
||||
* True when the subscription is attached to an org (regardless of plan
|
||||
* name). Feeds the permission resolution below.
|
||||
*/
|
||||
isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped),
|
||||
plan: subscriptionData?.data?.plan || 'free',
|
||||
status: subscriptionData?.data?.status || 'inactive',
|
||||
}
|
||||
|
||||
const isLegacyPlan = subscription.plan === 'pro' || subscription.plan === 'team'
|
||||
|
||||
/**
|
||||
* Sync the billing-period toggle to a paid subscriber's actual interval once
|
||||
* subscription data loads. Free/unsubscribed users keep the Annual default
|
||||
* (the API reports `billingInterval: 'month'` for them, which must not
|
||||
* override the default).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasInitializedInterval.current && subscription.isPaid) {
|
||||
hasInitializedInterval.current = true
|
||||
setIsAnnual(subscriptionData?.data?.billingInterval === 'year')
|
||||
}
|
||||
}, [subscription.isPaid, subscriptionData?.data?.billingInterval])
|
||||
|
||||
const userRole = subscriptionData?.data?.organization?.role ?? 'member'
|
||||
const isTeamAdmin = isOrgAdminRole(userRole)
|
||||
|
||||
const permissions = getSubscriptionPermissions(
|
||||
{
|
||||
isFree: subscription.isFree,
|
||||
isPro: subscription.isPro,
|
||||
isTeam: subscription.isTeam,
|
||||
isEnterprise: subscription.isEnterprise,
|
||||
isPaid: subscription.isPaid,
|
||||
isOrgScoped: subscription.isOrgScoped,
|
||||
plan: subscription.plan || 'free',
|
||||
status: subscription.status || 'inactive',
|
||||
},
|
||||
{ isTeamAdmin, userRole: userRole || 'member' }
|
||||
)
|
||||
|
||||
const showUpgradePlans = permissions.showUpgradePlans
|
||||
|
||||
const doUpgrade = useCallback(
|
||||
async (targetPlan: TargetPlan, creditTier: number, seats?: number) => {
|
||||
try {
|
||||
await handleUpgrade(targetPlan, {
|
||||
creditTier,
|
||||
annual: isAnnual,
|
||||
...(seats ? { seats } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Unknown error occurred'))
|
||||
}
|
||||
},
|
||||
[handleUpgrade, isAnnual]
|
||||
)
|
||||
|
||||
const currentInterval: 'month' | 'year' =
|
||||
subscriptionData?.data?.billingInterval === 'year' ? 'year' : 'month'
|
||||
|
||||
const handleSwitchInterval = useCallback(
|
||||
async (interval: 'month' | 'year') => {
|
||||
if (isLegacyPlan) {
|
||||
throw new Error(
|
||||
'Interval switching is not available on legacy plans. Please upgrade first.'
|
||||
)
|
||||
}
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: { targetPlanName: subscription.plan, interval },
|
||||
})
|
||||
await refetchSubscription()
|
||||
},
|
||||
[refetchSubscription, subscription.plan, isLegacyPlan]
|
||||
)
|
||||
|
||||
const currentCredits = getPlanTierCredits(subscription.plan)
|
||||
const hasPaidPlan = isPro(subscription.plan) || isTeam(subscription.plan)
|
||||
const isLegacyTeam = subscription.plan === 'team'
|
||||
const isOnKnownTier = currentCredits === PRO_TIER.credits || currentCredits === MAX_TIER.credits
|
||||
const isOnProTier =
|
||||
hasPaidPlan &&
|
||||
!isLegacyTeam &&
|
||||
(currentCredits === PRO_TIER.credits || (!isOnKnownTier && !subscription.isTeam))
|
||||
const isOnMaxTier =
|
||||
hasPaidPlan &&
|
||||
(currentCredits === MAX_TIER.credits || isLegacyTeam || (!isOnKnownTier && subscription.isTeam))
|
||||
const wantsIntervalSwitch =
|
||||
hasPaidPlan && !isLegacyPlan && isAnnual !== (currentInterval === 'year')
|
||||
const isOnPro = isOnProTier && !wantsIntervalSwitch
|
||||
const isOnMax = isOnMaxTier && !wantsIntervalSwitch
|
||||
|
||||
const upgradeOrSwitchToMax = useCallback(async () => {
|
||||
const planType = subscription.isTeam ? 'team' : 'pro'
|
||||
try {
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: {
|
||||
targetPlanName: `${planType}_${MAX_TIER.credits}`,
|
||||
interval: isAnnual ? 'year' : 'month',
|
||||
},
|
||||
})
|
||||
await refetchSubscription()
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e, 'Failed to upgrade'))
|
||||
}
|
||||
}, [subscription.isTeam, isAnnual, refetchSubscription])
|
||||
|
||||
const onUpgradeToOtherTier = useCallback(async () => {
|
||||
const onMax =
|
||||
getPlanTierCredits(subscription.plan) === MAX_TIER.credits || subscription.plan === 'team'
|
||||
const targetTier = onMax ? PRO_TIER : MAX_TIER
|
||||
const planType = subscription.isTeam ? 'team' : 'pro'
|
||||
const targetPlanName = `${planType}_${targetTier.credits}`
|
||||
try {
|
||||
await requestJson(billingSwitchPlanContract, {
|
||||
body: { targetPlanName },
|
||||
})
|
||||
await refetchSubscription()
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e, 'Failed to switch plan'))
|
||||
}
|
||||
}, [subscription.plan, subscription.isTeam, refetchSubscription])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isAnnual,
|
||||
setIsAnnual,
|
||||
subscription,
|
||||
showUpgradePlans,
|
||||
proTier: PRO_TIER,
|
||||
maxTier: MAX_TIER,
|
||||
isOnPro,
|
||||
isOnMax,
|
||||
isOnMaxTier,
|
||||
wantsIntervalSwitch,
|
||||
doUpgrade,
|
||||
handleSwitchInterval,
|
||||
upgradeOrSwitchToMax,
|
||||
onUpgradeToOtherTier,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'
|
||||
|
||||
export const metadata: Metadata = { title: 'Upgrade' }
|
||||
|
||||
export default async function UpgradePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string }>
|
||||
}) {
|
||||
const { workspaceId } = await params
|
||||
return (
|
||||
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
|
||||
<Upgrade workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Config for a plan's top-level credit stats.
|
||||
* When `credits` is omitted the credits/refresh block is not rendered.
|
||||
*/
|
||||
export interface PlanCredits {
|
||||
/** Formatted credits string, e.g. `"6,000 credits/mo"`. */
|
||||
credits: string
|
||||
/** Formatted daily-refresh string, e.g. `"+50/day refresh"`. */
|
||||
refresh: string
|
||||
}
|
||||
|
||||
export const PRO_PLAN_CREDITS: PlanCredits = {
|
||||
credits: '6,000 credits/mo',
|
||||
refresh: '+50/day refresh',
|
||||
}
|
||||
|
||||
export const MAX_PLAN_CREDITS: PlanCredits = {
|
||||
credits: '25,000 credits/mo',
|
||||
refresh: '+200/day refresh',
|
||||
}
|
||||
|
||||
export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
|
||||
credits: 'Custom',
|
||||
refresh: 'Custom',
|
||||
}
|
||||
|
||||
export const PRO_PLAN_FEATURES: readonly string[] = [
|
||||
'Invite teammates',
|
||||
'Deploy workflows as APIs',
|
||||
'Extended run timeouts',
|
||||
'More storage & tables',
|
||||
]
|
||||
|
||||
export const MAX_PLAN_FEATURES: readonly string[] = [
|
||||
'Invite teammates',
|
||||
'Sim Mailer & KB Live Sync',
|
||||
'Highest rate limits',
|
||||
'Expanded storage & tables',
|
||||
]
|
||||
|
||||
export const ENTERPRISE_PLAN_FEATURES: readonly string[] = [
|
||||
'Custom limits & infrastructure',
|
||||
'SSO & SOC2 compliance',
|
||||
'Access control & self-hosting',
|
||||
'Dedicated support',
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseAsStringLiteral } from 'nuqs/server'
|
||||
import { UPGRADE_REASONS } from '@/lib/billing/upgrade-reasons'
|
||||
|
||||
/**
|
||||
* Single source of truth for the upgrade page's `reason` query param.
|
||||
*
|
||||
* Nullable (no `.withDefault`): a clean URL means no reason and the page keeps
|
||||
* its generic header. Shared by the client (`useQueryState`) and any server
|
||||
* read via `createSearchParamsCache`.
|
||||
*/
|
||||
export const upgradeReasonParam = {
|
||||
key: 'reason',
|
||||
parser: parseAsStringLiteral(UPGRADE_REASONS),
|
||||
} as const
|
||||
|
||||
/** Clean URLs, no back-stack churn — the reason is a passive header hint. */
|
||||
export const upgradeUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface SubscriptionPermissions {
|
||||
canUpgradeToPro: boolean
|
||||
canUpgradeToTeam: boolean
|
||||
canViewEnterprise: boolean
|
||||
canManageTeam: boolean
|
||||
canEditUsageLimit: boolean
|
||||
canCancelSubscription: boolean
|
||||
showTeamMemberView: boolean
|
||||
showUpgradePlans: boolean
|
||||
isEnterpriseMember: boolean
|
||||
canViewUsageInfo: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionState {
|
||||
isFree: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
isPaid: boolean
|
||||
/**
|
||||
* True when the subscription's `referenceId` is an organization. Source
|
||||
* of truth for scope-based decisions — `pro_*` plans that have been
|
||||
* transferred to an org are org-scoped even though `isTeam` is false.
|
||||
*/
|
||||
isOrgScoped: boolean
|
||||
plan: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
isTeamAdmin: boolean
|
||||
userRole: string
|
||||
}
|
||||
|
||||
export function getSubscriptionPermissions(
|
||||
subscription: SubscriptionState,
|
||||
userRole: UserRole
|
||||
): SubscriptionPermissions {
|
||||
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
|
||||
const { isTeamAdmin } = userRole
|
||||
|
||||
// Non-admin org members see the "team member" view: no edit / no cancel
|
||||
// / no upgrade, pooled usage display.
|
||||
const orgMemberOnly = isOrgScoped && !isTeamAdmin
|
||||
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin
|
||||
|
||||
const isEnterpriseMember = isEnterprise && !isTeamAdmin
|
||||
const canViewUsageInfo = !isEnterpriseMember
|
||||
|
||||
return {
|
||||
canUpgradeToPro: isFree,
|
||||
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
|
||||
canViewEnterprise: !isEnterprise && !orgMemberOnly,
|
||||
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
|
||||
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
|
||||
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
|
||||
showTeamMemberView: orgMemberOnly,
|
||||
showUpgradePlans:
|
||||
(isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise,
|
||||
isEnterpriseMember,
|
||||
canViewUsageInfo,
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisiblePlans(
|
||||
subscription: SubscriptionState,
|
||||
userRole: UserRole
|
||||
): ('pro' | 'team' | 'enterprise')[] {
|
||||
const plans: ('pro' | 'team' | 'enterprise')[] = []
|
||||
const { isFree, isPro, isEnterprise, isOrgScoped } = subscription
|
||||
const { isTeamAdmin } = userRole
|
||||
|
||||
if (isFree) {
|
||||
plans.push('pro', 'team', 'enterprise')
|
||||
} else if (isPro && !isOrgScoped) {
|
||||
plans.push('team', 'enterprise')
|
||||
} else if (isOrgScoped && isTeamAdmin && !isEnterprise) {
|
||||
plans.push('enterprise')
|
||||
}
|
||||
|
||||
return plans
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Chip, toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
getUpgradeCardCta,
|
||||
type PlanCardCta,
|
||||
type PlanTier,
|
||||
type UpgradeCardId,
|
||||
} from '@/lib/billing/client'
|
||||
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
|
||||
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import {
|
||||
BillingPeriodToggle,
|
||||
ComparisonTable,
|
||||
UpgradePlanCard,
|
||||
} from '@/app/workspace/[workspaceId]/upgrade/components'
|
||||
import { useUpgradeState } from '@/app/workspace/[workspaceId]/upgrade/hooks'
|
||||
import {
|
||||
ENTERPRISE_PLAN_CREDITS,
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
MAX_PLAN_CREDITS,
|
||||
MAX_PLAN_FEATURES,
|
||||
PRO_PLAN_CREDITS,
|
||||
PRO_PLAN_FEATURES,
|
||||
} from '@/app/workspace/[workspaceId]/upgrade/plan-configs'
|
||||
import {
|
||||
upgradeReasonParam,
|
||||
upgradeUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/upgrade/search-params'
|
||||
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
|
||||
|
||||
const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF' as const
|
||||
|
||||
/**
|
||||
* Props for {@link Upgrade}.
|
||||
*/
|
||||
export interface UpgradeProps {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen Upgrade page. Renders the plan cards and the billing-period
|
||||
* toggle on top of the derived state from {@link useUpgradeState}. Plan and
|
||||
* billing management (payment method, cancellation, invoices) lives on the
|
||||
* Billing settings page.
|
||||
*/
|
||||
export function Upgrade({ workspaceId }: UpgradeProps) {
|
||||
const state = useUpgradeState()
|
||||
const router = useRouter()
|
||||
const origin = useFullscreenOriginStore((s) => s.origin)
|
||||
const [reason] = useQueryState(upgradeReasonParam.key, {
|
||||
...upgradeReasonParam.parser,
|
||||
...upgradeUrlKeys,
|
||||
})
|
||||
const [showAllFeatures, setShowAllFeatures] = useState(false)
|
||||
|
||||
const header = reason ? UPGRADE_REASON_COPY[reason].header : DEFAULT_UPGRADE_HEADER
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
router.replace(origin ?? `/workspace/${workspaceId}/home`)
|
||||
}, [origin, router, workspaceId])
|
||||
|
||||
// Enterprise manages billing out-of-band, and self-hosted deployments with
|
||||
// billing disabled have no plans to surface — redirect to home in both cases.
|
||||
useEffect(() => {
|
||||
if (!isBillingEnabled) {
|
||||
router.replace(`/workspace/${workspaceId}/home`)
|
||||
return
|
||||
}
|
||||
if (!state.isLoading && state.subscription.isEnterprise) {
|
||||
router.replace(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
}, [state.isLoading, state.subscription.isEnterprise, router, workspaceId])
|
||||
|
||||
if (!isBillingEnabled || state.isLoading || state.subscription.isEnterprise) return null
|
||||
|
||||
// Enterprise is redirected above, so the current plan is only ever free/pro/max here.
|
||||
const planTier: PlanTier = state.subscription.isFree ? 'free' : state.isOnMaxTier ? 'max' : 'pro'
|
||||
|
||||
/**
|
||||
* Resolve a card's CTA from the canonical matrix, then bind it to the matching
|
||||
* handler. A same-tier "Manage plan" card flips to an interval switch when the
|
||||
* billing-period toggle differs from the active subscription interval.
|
||||
*/
|
||||
const resolveCta = (
|
||||
card: UpgradeCardId
|
||||
): PlanCardCta & { onClick: () => void; disabled?: boolean } => {
|
||||
const cta = getUpgradeCardCta(planTier, card)
|
||||
|
||||
if (cta.intent === 'manage') {
|
||||
// Same-tier card. A billing-period toggle mismatch turns it into an
|
||||
// interval switch; otherwise it's a non-actionable "Current Plan" marker
|
||||
// (plan management lives on the Billing settings page).
|
||||
if (state.wantsIntervalSwitch) {
|
||||
return {
|
||||
...cta,
|
||||
label: `Switch to ${state.isAnnual ? 'Annual' : 'Monthly'}`,
|
||||
onClick: () =>
|
||||
state
|
||||
.handleSwitchInterval(state.isAnnual ? 'year' : 'month')
|
||||
.catch((e) => toast.error(getErrorMessage(e, 'Failed to switch interval'))),
|
||||
}
|
||||
}
|
||||
return { ...cta, onClick: () => {}, disabled: true }
|
||||
}
|
||||
|
||||
const onClick = (): void => {
|
||||
switch (cta.intent) {
|
||||
case 'sales':
|
||||
window.open(TYPEFORM_ENTERPRISE_URL, '_blank')
|
||||
return
|
||||
case 'downgrade':
|
||||
void state.onUpgradeToOtherTier()
|
||||
return
|
||||
case 'upgrade':
|
||||
if (card === 'max') {
|
||||
if (state.subscription.isPaid) void state.upgradeOrSwitchToMax()
|
||||
else state.doUpgrade('pro', state.maxTier.credits)
|
||||
} else {
|
||||
state.doUpgrade('pro', state.proTier.credits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...cta, onClick }
|
||||
}
|
||||
|
||||
const proCta = resolveCta('pro')
|
||||
const maxCta = resolveCta('max')
|
||||
const enterpriseCta = resolveCta('enterprise')
|
||||
|
||||
// Comparison-table CTAs reuse the card CTAs verbatim so both stay in sync.
|
||||
// Free has no card and intentionally renders no button.
|
||||
const comparisonCtas = { Pro: proCta, Max: maxCta, Enterprise: enterpriseCta }
|
||||
|
||||
const proBanner = state.isOnPro ? 'Your plan' : undefined
|
||||
const maxBanner = state.isOnMax ? 'Your plan' : undefined
|
||||
|
||||
const discountPct = Math.round(ANNUAL_DISCOUNT_RATE * 100)
|
||||
const proPrice = state.isAnnual
|
||||
? Math.round(state.proTier.dollars * (1 - ANNUAL_DISCOUNT_RATE))
|
||||
: state.proTier.dollars
|
||||
const maxPrice = state.isAnnual
|
||||
? Math.round(state.maxTier.dollars * (1 - ANNUAL_DISCOUNT_RATE))
|
||||
: state.maxTier.dollars
|
||||
const priceSubtext = state.isAnnual
|
||||
? 'per user/month, billed annually'
|
||||
: 'per user/month, billed monthly'
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
|
||||
<Chip leftIcon={ArrowLeft} onClick={handleBack}>
|
||||
Back
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='mx-auto flex w-full max-w-[960px] flex-col gap-7 pt-6 pb-3'>
|
||||
<div className='flex flex-col items-center gap-4'>
|
||||
<h1 className='text-balance text-center font-season text-[30px] text-[var(--text-primary)]'>
|
||||
{header}
|
||||
</h1>
|
||||
{state.showUpgradePlans && (
|
||||
<BillingPeriodToggle isAnnual={state.isAnnual} onChange={state.setIsAnnual} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.showUpgradePlans && (
|
||||
<>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<UpgradePlanCard
|
||||
name='Pro'
|
||||
price={`$${proPrice}`}
|
||||
discountLabel={state.isAnnual ? `${discountPct}% off` : undefined}
|
||||
priceSubtext={priceSubtext}
|
||||
segmentLabel='For growing teams'
|
||||
credits={PRO_PLAN_CREDITS.credits}
|
||||
refresh={PRO_PLAN_CREDITS.refresh}
|
||||
features={PRO_PLAN_FEATURES}
|
||||
buttonText={proCta.label}
|
||||
onButtonClick={proCta.onClick}
|
||||
buttonDisabled={proCta.disabled}
|
||||
highlighted={proCta.variant === 'primary'}
|
||||
bannerText={proBanner}
|
||||
/>
|
||||
|
||||
<UpgradePlanCard
|
||||
name='Max'
|
||||
price={`$${maxPrice}`}
|
||||
discountLabel={state.isAnnual ? `${discountPct}% off` : undefined}
|
||||
priceSubtext={priceSubtext}
|
||||
segmentLabel='For scaling businesses'
|
||||
credits={MAX_PLAN_CREDITS.credits}
|
||||
refresh={MAX_PLAN_CREDITS.refresh}
|
||||
features={MAX_PLAN_FEATURES}
|
||||
buttonText={maxCta.label}
|
||||
onButtonClick={maxCta.onClick}
|
||||
buttonDisabled={maxCta.disabled}
|
||||
highlighted={maxCta.variant === 'primary'}
|
||||
bannerText={maxBanner}
|
||||
/>
|
||||
|
||||
<UpgradePlanCard
|
||||
name='Enterprise'
|
||||
price='Custom'
|
||||
segmentLabel='For large organizations'
|
||||
credits={ENTERPRISE_PLAN_CREDITS.credits}
|
||||
refresh={ENTERPRISE_PLAN_CREDITS.refresh}
|
||||
features={ENTERPRISE_PLAN_FEATURES}
|
||||
buttonText={enterpriseCta.label}
|
||||
onButtonClick={enterpriseCta.onClick}
|
||||
buttonDisabled={enterpriseCta.disabled}
|
||||
highlighted={enterpriseCta.variant === 'primary'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show / Hide all features */}
|
||||
<div className='flex flex-col items-center gap-6'>
|
||||
<Chip
|
||||
onClick={() => setShowAllFeatures((prev) => !prev)}
|
||||
aria-expanded={showAllFeatures}
|
||||
>
|
||||
{showAllFeatures ? 'Hide all features' : 'Show all features'}
|
||||
</Chip>
|
||||
|
||||
{showAllFeatures && (
|
||||
<ComparisonTable
|
||||
proPrice={`$${proPrice}`}
|
||||
maxPrice={`$${maxPrice}`}
|
||||
isAnnual={state.isAnnual}
|
||||
onIsAnnualChange={state.setIsAnnual}
|
||||
ctas={comparisonCtas}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user