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:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
PricingCard,
|
||||
type PricingCardCta,
|
||||
type PricingCardProps,
|
||||
type PricingCardSection,
|
||||
} from './pricing-card'
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Check, ChipLink, ChipTag, cn } from '@sim/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import type { CellValue } 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 pricing card - label, chip variant, and destination href. */
|
||||
export interface PricingCardCta {
|
||||
label: string
|
||||
variant: 'primary' | 'border-shadow'
|
||||
href: string
|
||||
}
|
||||
|
||||
/** A labelled group of feature rows for one plan, transposed from the comparison data. */
|
||||
export interface PricingCardSection {
|
||||
/** Stable key for the section, used for React reconciliation. */
|
||||
key: string
|
||||
/** Section header, e.g. `"Rate limits (runs/min)"`. Omit to render the rows without a header. */
|
||||
title?: string
|
||||
/** Feature rows - this plan's value for each comparison row in the section. */
|
||||
rows: { label: string; value: CellValue }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link PricingCard}.
|
||||
*/
|
||||
export interface PricingCardProps {
|
||||
/** Plan name, e.g. `"Pro"`. */
|
||||
name: string
|
||||
/** Headline price, e.g. `"$25"`, `"$0"`, or `"Custom"`. */
|
||||
price: string
|
||||
/** Small line under the price, e.g. `"per user/month, billed monthly"`. */
|
||||
priceSubtext?: string
|
||||
/** Optional discount pill next to the price, e.g. `"15% off"`. */
|
||||
discountLabel?: string
|
||||
/** Resolved CTA - label, variant, and destination href. */
|
||||
cta: PricingCardCta
|
||||
/** Full feature breakdown for this plan, grouped by section. */
|
||||
sections: PricingCardSection[]
|
||||
/** Extra outer classes. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one plan's value for a feature row: `true` → check, `false` → em-dash,
|
||||
* an icon reference → its brand icon, a string → right-aligned text.
|
||||
*/
|
||||
function FeatureValue({ value }: { value: CellValue }) {
|
||||
if (value === true) {
|
||||
return <Check className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
if (value === false) {
|
||||
return <span className='select-none text-[var(--text-muted)]'>–</span>
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const Icon = CELL_ICONS[value.icon]
|
||||
return <Icon className='size-[14px] flex-shrink-0' />
|
||||
}
|
||||
return (
|
||||
<span className='whitespace-nowrap text-right text-[var(--text-primary)] text-sm tabular-nums'>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public pricing card - one plan rendered as a self-contained spec sheet: the
|
||||
* header (name, price, CTA, segment) above the full comparison breakdown, grouped
|
||||
* by section with a hairline rule under each section header.
|
||||
*
|
||||
* The radius (`rounded-lg`) matches the landing hero's visual panel so the cards
|
||||
* line up with the rest of the landing chrome. Every card carries the identical
|
||||
* row structure (only values differ) so a grid of them renders at exactly the
|
||||
* same height. Feature rows are transposed from the shared comparison data, so
|
||||
* the card can never drift from the platform.
|
||||
*/
|
||||
export function PricingCard({
|
||||
name,
|
||||
price,
|
||||
priceSubtext,
|
||||
discountLabel,
|
||||
cta,
|
||||
sections,
|
||||
className,
|
||||
}: PricingCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'flex h-full flex-col gap-[22px] rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] p-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-[22px]'>
|
||||
<h2 className='text-[24px] text-[var(--text-primary)]'>{name}</h2>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='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 ?? ' '}</p>
|
||||
</div>
|
||||
|
||||
<ChipLink
|
||||
href={cta.href}
|
||||
variant={cta.variant}
|
||||
fullWidth
|
||||
flush
|
||||
className='w-full justify-center'
|
||||
>
|
||||
{cta.label}
|
||||
</ChipLink>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-5'>
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className='flex flex-col'>
|
||||
{section.title && (
|
||||
<>
|
||||
<span className='text-[var(--text-primary)] text-small'>{section.title}</span>
|
||||
<div className='mt-2 mb-2.5 h-px bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex flex-col gap-2.5'>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.label} className='flex items-center justify-between gap-3'>
|
||||
<span className='text-[var(--text-body)] text-sm'>{row.label}</span>
|
||||
<FeatureValue value={row.value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingPlans, type PricingPlansProps } from './pricing-plans'
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { getUpgradeCardCta, type PlanTier, type UpgradeCardId } from '@/lib/billing/client'
|
||||
import { ANNUAL_DISCOUNT_RATE, CREDIT_TIERS } from '@/lib/billing/constants'
|
||||
import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
|
||||
import {
|
||||
PricingCard,
|
||||
type PricingCardCta,
|
||||
type PricingCardSection,
|
||||
} from '@/app/(landing)/pricing/components/pricing-card'
|
||||
import { pricingParsers, pricingUrlKeys } from '@/app/(landing)/pricing/search-params'
|
||||
import { BillingPeriodToggle } from '@/app/workspace/[workspaceId]/upgrade/components'
|
||||
import {
|
||||
type CellValue,
|
||||
COMPARISON_SECTIONS,
|
||||
} from '@/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data'
|
||||
|
||||
/**
|
||||
* A public visitor has no subscription, so each card's canonical label and
|
||||
* variant resolves against the `free` tier. On this page every self-serve CTA
|
||||
* funnels to sign-up regardless of plan; the enterprise CTA routes to the
|
||||
* demo-request form instead.
|
||||
*/
|
||||
const VISITOR_TIER: PlanTier = 'free'
|
||||
|
||||
/** This section's rows render on the public cards without their group header. */
|
||||
const HEADERLESS_SECTION_TITLE = 'Credits & pricing'
|
||||
|
||||
/**
|
||||
* Props for {@link PricingPlans}.
|
||||
*/
|
||||
export interface PricingPlansProps {
|
||||
/**
|
||||
* Server-rendered heading slot - the page `<h1>` and its sr-only GEO summary.
|
||||
* Passed as a slot so the crawlable copy stays in the server payload.
|
||||
*/
|
||||
heading: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Transpose the shared comparison data into one plan column's feature sections,
|
||||
* so each card carries the full breakdown for its plan without duplicating it.
|
||||
*/
|
||||
function sectionsForColumn(col: number): PricingCardSection[] {
|
||||
return COMPARISON_SECTIONS.map((section) => ({
|
||||
key: section.title,
|
||||
title: section.title === HEADERLESS_SECTION_TITLE ? undefined : section.title,
|
||||
rows: section.rows.map((row) => ({ label: row.label, value: row.values[col] as CellValue })),
|
||||
}))
|
||||
}
|
||||
|
||||
/** The four card columns (Free, Pro, Max, Enterprise) transposed once at module load - the source data is static, so this never needs to be redone per render. */
|
||||
const SECTIONS_BY_COLUMN = [0, 1, 2, 3].map(sectionsForColumn)
|
||||
|
||||
/** Round a dollar amount down to the annual-billing discounted price. */
|
||||
function annualPrice(dollars: number): number {
|
||||
return Math.round(dollars * (1 - ANNUAL_DISCOUNT_RATE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a card's canonical label and variant from the free-tier matrix. A
|
||||
* `sales` intent ("Talk to sales") routes to the demo-request form, matching
|
||||
* every other "Contact sales" CTA on the landing site; every other intent
|
||||
* funnels logged-out visitors to sign-up.
|
||||
*/
|
||||
function resolveCta(card: UpgradeCardId): PricingCardCta {
|
||||
const cta = getUpgradeCardCta(VISITOR_TIER, card)
|
||||
return {
|
||||
label: cta.label,
|
||||
variant: cta.variant,
|
||||
href: cta.intent === 'sales' ? DEMO_HREF : SIGNUP_HREF,
|
||||
}
|
||||
}
|
||||
|
||||
const FREE_CTA: PricingCardCta = {
|
||||
label: 'Get started',
|
||||
variant: 'border-shadow',
|
||||
href: SIGNUP_HREF,
|
||||
}
|
||||
|
||||
/**
|
||||
* The single interactive island of the public pricing page. It owns the lone
|
||||
* piece of state - `isAnnual`, defaulting to monthly - which the billing-period
|
||||
* toggle (centered under the heading) and the Pro/Max card prices read from one
|
||||
* source.
|
||||
*
|
||||
* Each plan (Free, Pro, Max, Enterprise) renders as a full {@link PricingCard}
|
||||
* spec sheet in a four-up grid that renders at exactly the same height. Prices
|
||||
* and the discount come from {@link CREDIT_TIERS} / {@link ANNUAL_DISCOUNT_RATE},
|
||||
* the feature breakdown is transposed from the shared `COMPARISON_SECTIONS`, and
|
||||
* the CTA labels/variants from {@link getUpgradeCardCta}. Every CTA, Free
|
||||
* included, funnels to `/signup`.
|
||||
*/
|
||||
export function PricingPlans({ heading }: PricingPlansProps) {
|
||||
const [{ billing }, setParams] = useQueryStates(pricingParsers, pricingUrlKeys)
|
||||
const isAnnual = billing === 'annual'
|
||||
const setIsAnnual = (next: boolean) => setParams({ billing: next ? 'annual' : 'monthly' })
|
||||
|
||||
const discountPct = Math.round(ANNUAL_DISCOUNT_RATE * 100)
|
||||
const proPrice = isAnnual ? annualPrice(CREDIT_TIERS[0].dollars) : CREDIT_TIERS[0].dollars
|
||||
const maxPrice = isAnnual ? annualPrice(CREDIT_TIERS[1].dollars) : CREDIT_TIERS[1].dollars
|
||||
const priceSubtext = isAnnual
|
||||
? 'per user/month, billed annually'
|
||||
: 'per user/month, billed monthly'
|
||||
const discountLabel = isAnnual ? `${discountPct}% off` : undefined
|
||||
|
||||
const proCta = resolveCta('pro')
|
||||
const maxCta = resolveCta('max')
|
||||
const enterpriseCta = resolveCta('enterprise')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-center gap-4'>
|
||||
{heading}
|
||||
<BillingPeriodToggle isAnnual={isAnnual} onChange={setIsAnnual} />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<PricingCard
|
||||
name='Free'
|
||||
price='$0'
|
||||
priceSubtext='Free forever'
|
||||
cta={FREE_CTA}
|
||||
sections={SECTIONS_BY_COLUMN[0]}
|
||||
/>
|
||||
<PricingCard
|
||||
name='Pro'
|
||||
price={`$${proPrice}`}
|
||||
discountLabel={discountLabel}
|
||||
priceSubtext={priceSubtext}
|
||||
cta={proCta}
|
||||
sections={SECTIONS_BY_COLUMN[1]}
|
||||
/>
|
||||
<PricingCard
|
||||
name='Max'
|
||||
price={`$${maxPrice}`}
|
||||
discountLabel={discountLabel}
|
||||
priceSubtext={priceSubtext}
|
||||
cta={maxCta}
|
||||
sections={SECTIONS_BY_COLUMN[2]}
|
||||
/>
|
||||
<PricingCard
|
||||
name='Enterprise'
|
||||
price='Custom'
|
||||
priceSubtext='Tailored to your team'
|
||||
cta={enterpriseCta}
|
||||
sections={SECTIONS_BY_COLUMN[3]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingStructuredData } from './pricing-structured-data'
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { CREDIT_TIERS } from '@/lib/billing/constants'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { COMPARISON_SECTIONS } from '@/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data'
|
||||
|
||||
const PAGE_URL = `${SITE_URL}/pricing`
|
||||
|
||||
/** Feature list derived from the comparison data the cards render, so it can't drift. */
|
||||
const FEATURE_LIST = Array.from(
|
||||
new Set(COMPARISON_SECTIONS.flatMap((section) => section.rows.map((row) => row.label)))
|
||||
)
|
||||
|
||||
const PRICING_JSON_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebPage',
|
||||
'@id': `${PAGE_URL}#webpage`,
|
||||
url: PAGE_URL,
|
||||
name: 'Pricing | Sim, the AI Workspace',
|
||||
description:
|
||||
'Pricing for Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Compare the Free, Pro, Max, and Enterprise plans.',
|
||||
isPartOf: { '@id': `${SITE_URL}#website` },
|
||||
about: { '@id': `${PAGE_URL}#application` },
|
||||
breadcrumb: { '@id': `${PAGE_URL}#breadcrumb` },
|
||||
inLanguage: 'en-US',
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${PAGE_URL}#breadcrumb`,
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Pricing', item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
'@id': `${PAGE_URL}#application`,
|
||||
name: 'Sim',
|
||||
description:
|
||||
'Sim is the open-source AI workspace where teams build, deploy, and manage AI agents, connecting 1,000+ integrations and every major LLM.',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
url: SITE_URL,
|
||||
featureList: FEATURE_LIST,
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
description: 'Start building AI agents for free.',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Pro',
|
||||
price: String(CREDIT_TIERS[0].dollars),
|
||||
priceCurrency: 'USD',
|
||||
description: 'For growing teams. Billed per user/month.',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Max',
|
||||
price: String(CREDIT_TIERS[1].dollars),
|
||||
priceCurrency: 'USD',
|
||||
description: 'For scaling businesses. Billed per user/month.',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Enterprise',
|
||||
priceCurrency: 'USD',
|
||||
description: 'Custom limits and infrastructure for large organizations.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-LD for the public pricing page - a `WebPage` (about a `WebApplication`),
|
||||
* a `BreadcrumbList`, and the `WebApplication` (Sim) carrying one `Offer` per
|
||||
* plan tier. Rendered server-side before any visible content so crawlers and AI
|
||||
* answer engines read the structured pricing first. Mirrors the platform/
|
||||
* solutions structured-data shape so the landing family stays consistent.
|
||||
*
|
||||
* Everything is derived from the same shared sources that drive the visible cards
|
||||
* - Pro/Max monthly prices from {@link CREDIT_TIERS} and the `featureList` from
|
||||
* the shared `COMPARISON_SECTIONS` - so the structured data can never drift from
|
||||
* the page. Free is `$0`; Enterprise is custom and intentionally ships no price.
|
||||
*
|
||||
* Server Component; no client cost.
|
||||
*/
|
||||
export function PricingStructuredData() {
|
||||
return <JsonLd data={PRICING_JSON_LD} />
|
||||
}
|
||||
Reference in New Issue
Block a user