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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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'
@@ -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],
},
],
},
]
@@ -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>
)
}