diff --git a/COMMERCIAL.md b/COMMERCIAL.md index 963690f8..421a62ff 100644 --- a/COMMERCIAL.md +++ b/COMMERCIAL.md @@ -4,12 +4,10 @@ Most libraries in this repository — `@ngaf/render`, `@ngaf/agent`, `@ngaf/lang ## `@ngaf/chat` -Starting with the next published version, `@ngaf/chat` is dual-licensed: +`@ngaf/chat` is dual-licensed: -- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30-day commercial evaluation). -- **Threadplane commercial license** for commercial production use. - -Historical MIT releases of `@ngaf/chat` remain under their original terms. +- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30 calendar days of commercial evaluation from first commercial use). +- **ThreadPlane Commercial license** for commercial production use. Sold via [threadplane.ai/pricing](https://threadplane.ai/pricing); see [/docs/licensing](https://threadplane.ai/docs/licensing) for installation. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/LICENSE-COMMERCIAL.md`](./libs/chat/LICENSE-COMMERCIAL.md), and [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md) for the full terms. diff --git a/README.md b/README.md index d818dc90..0e4c078c 100644 --- a/README.md +++ b/README.md @@ -131,4 +131,4 @@ That's it. `chat.messages()` and `chat.status()` are Angular Signals. Bind them Most libraries in this repository (`@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/licensing`, `@ngaf/telemetry`, `@ngaf/design-tokens`) are released under the **MIT License** — free for any use, including commercial, with attribution. -**`@ngaf/chat`** is the exception. Future versions are licensed under **PolyForm Noncommercial 1.0.0 OR a Threadplane commercial license**. Historical npm releases remain MIT. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), and [`COMMERCIAL.md`](./COMMERCIAL.md) for details. +**`@ngaf/chat`** is the exception. It is dual-licensed under **PolyForm Noncommercial 1.0.0** for free noncommercial use, or a **ThreadPlane Commercial license** for production use inside a for-profit context. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), [`COMMERCIAL.md`](./COMMERCIAL.md), and [threadplane.ai/docs/licensing](https://threadplane.ai/docs/licensing) for details. diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts index f98f6a4b..d580fba0 100644 --- a/apps/minting-service/scripts/remint.ts +++ b/apps/minting-service/scripts/remint.ts @@ -57,7 +57,7 @@ export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise { expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----'); + expect(out.text).toContain('-----BEGIN THREADPLANE LICENSE-----'); expect(out.text).toContain('PAYLOAD.SIG'); - expect(out.text).toContain('-----END CACHEPLANE LICENSE-----'); + expect(out.text).toContain('-----END THREADPLANE LICENSE-----'); }); it('subject includes tier and seat count with plural s for seats > 1', () => { @@ -27,12 +27,12 @@ describe('renderLicenseEmail', () => { it('subject uses singular seat for seats === 1', () => { const out = renderLicenseEmail({ - tier: 'app_deployment', + tier: 'team', seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.subject).toBe('Your ThreadPlane license — app_deployment (1 seat)'); + expect(out.subject).toBe('Your ThreadPlane license — team (1 seat)'); }); it('includes ISO 8601 UTC expiry in text body', () => { @@ -54,6 +54,6 @@ describe('renderLicenseEmail', () => { }); expect(out.html).toContain(' + // .env + THREADPLANE_LICENSE= Docs: https://threadplane.ai/docs/licensing Questions: reply to this email. @@ -48,16 +51,21 @@ Questions: reply to this email. -- The ThreadPlane team `; - const html = `

Thanks for subscribing to ThreadPlane.

-

Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:

-
-----BEGIN CACHEPLANE LICENSE-----
+  const html = `

Thanks for your ThreadPlane license purchase.

+

Your license is valid for 12 months from today. Paste the token below into your @ngaf/chat configuration:

+
-----BEGIN THREADPLANE LICENSE-----
 ${escapeHtml(vars.token)}
------END CACHEPLANE LICENSE-----
+-----END THREADPLANE LICENSE-----

Tier: ${escapeHtml(vars.tier)}
Seats: ${vars.seats}
Expires: ${escapeHtml(expiresIso)}

Installation:

-
export CACHEPLANE_LICENSE="<paste token above>"
+
provideChat({
+  license: process.env['THREADPLANE_LICENSE'],
+});
+
+// .env
+THREADPLANE_LICENSE=<paste token above>

Docs: threadplane.ai/docs/licensing
Questions: reply to this email.

-- The ThreadPlane team

diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 357a7d0c..e9d11d43 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -39,7 +39,7 @@ function paymentSession(overrides: Partial = {}): Strip { quantity: 1, price: { - metadata: { ngaf_tier_slug: 'indie' }, + metadata: { ngaf_tier_slug: 'developer_seat' }, } as Stripe.Price, } as Stripe.LineItem, ], @@ -110,7 +110,7 @@ describe('handleCheckoutCompleted', () => { expect(deps.mintToken).toHaveBeenCalledWith( expect.objectContaining({ stripeCustomerId: 'cus_test_123', - tier: 'indie', + tier: 'developer_seat', seats: 1, expiresAt: expect.any(Date), }), @@ -122,7 +122,7 @@ describe('handleCheckoutCompleted', () => { stripeCustomerId: 'cus_test_123', stripePaymentId: 'pi_test_123', customerEmail: 'buyer@example.com', - tier: 'indie', + tier: 'developer_seat', seats: 1, lastToken: 'mock.token', }), @@ -131,7 +131,7 @@ describe('handleCheckoutCompleted', () => { expect.objectContaining({ from: 'noreply@example.com', to: 'buyer@example.com', - vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }), + vars: expect.objectContaining({ tier: 'developer_seat', seats: 1, token: 'mock.token' }), }), ); }); @@ -177,7 +177,7 @@ describe('handleCheckoutCompleted', () => { const deps = makeDeps({ getLicense: vi.fn().mockResolvedValue({ customerEmail: 'buyer@example.com', - tier: 'indie', + tier: 'developer_seat', }), revokeLicense: vi.fn().mockResolvedValue({}), }); @@ -186,7 +186,7 @@ describe('handleCheckoutCompleted', () => { expect(deps.sendRevocationEmail).toHaveBeenCalledWith( expect.objectContaining({ to: 'buyer@example.com', - vars: { tier: 'indie' }, + vars: { tier: 'developer_seat' }, }), ); }); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index a85109ea..384a48d5 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -168,6 +168,6 @@ export async function handleChargeRefunded( resendApiKey: deps.resendApiKey, from: deps.emailFrom, to: existing.customerEmail, - vars: { tier: existing.tier as 'indie' | 'developer_seat' | 'app_deployment' }, + vars: { tier: existing.tier as 'developer_seat' | 'team' }, }); } diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts index 433f4d86..21085f56 100644 --- a/apps/minting-service/src/lib/sign.spec.ts +++ b/apps/minting-service/src/lib/sign.spec.ts @@ -42,7 +42,7 @@ describe('mintToken', () => { mintToken( { stripeCustomerId: 'cus_x', - tier: 'app_deployment', + tier: 'team', seats: 1, expiresAt: new Date('2027-01-01T00:00:00Z'), }, diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts index 274ecbf6..b0de2a2a 100644 --- a/apps/minting-service/src/lib/tier.spec.ts +++ b/apps/minting-service/src/lib/tier.spec.ts @@ -2,16 +2,12 @@ import { extractTier, computeSeats } from './tier.js'; describe('extractTier', () => { - it('returns indie from price metadata', () => { - expect(extractTier({ ngaf_tier_slug: 'indie' })).toBe('indie'); - }); - it('returns developer_seat from price metadata', () => { expect(extractTier({ ngaf_tier_slug: 'developer_seat' })).toBe('developer_seat'); }); - it('returns app_deployment from price metadata', () => { - expect(extractTier({ ngaf_tier_slug: 'app_deployment' })).toBe('app_deployment'); + it('returns team from price metadata', () => { + expect(extractTier({ ngaf_tier_slug: 'team' })).toBe('team'); }); it('throws when ngaf_tier_slug is missing', () => { @@ -32,12 +28,9 @@ describe('computeSeats', () => { expect(computeSeats('developer_seat', 5)).toBe(5); }); - it('returns 1 for app_deployment regardless of quantity', () => { - expect(computeSeats('app_deployment', 10)).toBe(1); - }); - - it('returns 1 for indie regardless of quantity', () => { - expect(computeSeats('indie', 10)).toBe(1); + it('returns 5 for team regardless of quantity', () => { + expect(computeSeats('team', 1)).toBe(5); + expect(computeSeats('team', 10)).toBe(5); }); it('defaults developer_seat to 1 when quantity is null', () => { diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts index ae593248..c99a3e0c 100644 --- a/apps/minting-service/src/lib/tier.ts +++ b/apps/minting-service/src/lib/tier.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import type { LicenseTier } from '@ngaf/licensing'; -export type MintableTier = Extract; +export type MintableTier = Extract; -const VALID_TIERS: readonly MintableTier[] = ['indie', 'developer_seat', 'app_deployment'] as const; +const VALID_TIERS: readonly MintableTier[] = ['developer_seat', 'team'] as const; const METADATA_KEY = 'ngaf_tier_slug'; +const TEAM_SEAT_COUNT = 5; + /** * Extract the tier slug from a Stripe price metadata bag. * Throws if the field is missing or holds an unknown value. @@ -27,12 +29,14 @@ export function extractTier(metadata: Record | null | undefined) /** * Compute the `seats` claim from the Stripe line-item quantity. * - developer_seat: tracks Stripe quantity (minimum 1). - * - indie: always 1. - * - app_deployment: always 1. + * - team: always 5 (the bundle size baked into the SKU). */ export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number { if (tier === 'developer_seat') { return quantity && quantity > 0 ? quantity : 1; } + if (tier === 'team') { + return TEAM_SEAT_COUNT; + } return 1; } diff --git a/apps/website/content/docs/licensing/api/api-docs.json b/apps/website/content/docs/licensing/api/api-docs.json index 9f450918..5d8e42be 100644 --- a/apps/website/content/docs/licensing/api/api-docs.json +++ b/apps/website/content/docs/licensing/api/api-docs.json @@ -158,7 +158,7 @@ "name": "LicenseTier", "kind": "type", "description": "The tier a license grants.", - "signature": "\"indie\" | \"developer_seat\" | \"app_deployment\" | \"enterprise\"", + "signature": "\"developer_seat\" | \"team\" | \"enterprise\"", "examples": [] }, { diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts index cc61e1fa..510f90de 100644 --- a/apps/website/src/app/api/checkout/session/route.spec.ts +++ b/apps/website/src/app/api/checkout/session/route.spec.ts @@ -10,9 +10,8 @@ vi.mock('../../../../lib/stripe', () => ({ vi.mock('../../../../../../../pricing/tiers.generated', () => ({ STRIPE_PRICE_IDS: { - indie: 'price_test_indie', - developer_seat: 'price_test_seat', - app_deployment: 'price_test_app', + developer_seat: { monthly: 'price_seat_monthly', annual: 'price_seat_annual' }, + team: { monthly: 'price_team_monthly', annual: 'price_team_annual' }, }, })); @@ -47,17 +46,31 @@ describe('POST /api/checkout/session', () => { expect(res.status).toBe(400); }); - it('returns 303 redirect to Stripe for indie', async () => { - const res = await POST(makeReq({ tier: 'indie' })); - expect(res.status).toBe(303); - expect(res.headers.get('location')).toBe('https://checkout.stripe.com/c/pay/cs_test_abc'); - expect(stripeCreate).toHaveBeenCalledTimes(1); + it('returns 400 for invalid billing_cycle', async () => { + const res = await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'weekly' })); + expect(res.status).toBe(400); + }); + + it('defaults to annual billing cycle when omitted', async () => { + await POST(makeReq({ tier: 'developer_seat' })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.mode).toBe('subscription'); + expect(args.line_items[0].price).toBe('price_seat_annual'); + expect(args.metadata.ngaf_billing_cycle).toBe('annual'); + }); + + it('routes to the monthly price when billing_cycle=monthly', async () => { + await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'monthly' })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.line_items[0].price).toBe('price_seat_monthly'); + expect(args.metadata.ngaf_billing_cycle).toBe('monthly'); + }); + + it('routes Team to the team annual price by default', async () => { + await POST(makeReq({ tier: 'team' })); const args = stripeCreate.mock.calls[0]?.[0]; - expect(args.mode).toBe('payment'); - expect(args.customer_creation).toBe('always'); - expect(args.line_items[0].price).toBe('price_test_indie'); - expect(args.line_items[0].quantity).toBe(1); - expect(args.metadata.ngaf_tier_slug).toBe('indie'); + expect(args.line_items[0].price).toBe('price_team_annual'); + expect(args.metadata.ngaf_tier_slug).toBe('team'); }); it('enables adjustable_quantity only for developer_seat', async () => { @@ -78,7 +91,7 @@ describe('POST /api/checkout/session', () => { it('returns 502 if Stripe returns no URL', async () => { stripeCreate.mockResolvedValueOnce({ url: null }); - const res = await POST(makeReq({ tier: 'indie' })); + const res = await POST(makeReq({ tier: 'developer_seat' })); expect(res.status).toBe(502); }); }); diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts index 7362a862..e75d5657 100644 --- a/apps/website/src/app/api/checkout/session/route.ts +++ b/apps/website/src/app/api/checkout/session/route.ts @@ -1,13 +1,19 @@ // SPDX-License-Identifier: MIT import { NextRequest, NextResponse } from 'next/server'; import { getStripe } from '../../../../lib/stripe'; -import { TIERS, type TierSlug } from '../../../../../../../pricing/tiers.config'; +import { + TIERS, + type TierSlug, + type BillingCycle, +} from '../../../../../../../pricing/tiers.config'; import { STRIPE_PRICE_IDS } from '../../../../../../../pricing/tiers.generated'; -const BUYABLE_SLUGS = new Set(['indie', 'developer_seat', 'app_deployment']); +const BUYABLE_SLUGS = new Set(['developer_seat', 'team']); +const VALID_CYCLES = new Set(['monthly', 'annual']); interface RequestBody { tier?: string; + billing_cycle?: string; quantity?: number; } @@ -31,6 +37,8 @@ export async function POST(req: NextRequest) { const form = await req.formData(); body = { tier: typeof form.get('tier') === 'string' ? (form.get('tier') as string) : undefined, + billing_cycle: + typeof form.get('billing_cycle') === 'string' ? (form.get('billing_cycle') as string) : undefined, quantity: form.get('quantity') ? Number(form.get('quantity')) : undefined, }; } @@ -41,10 +49,16 @@ export async function POST(req: NextRequest) { } const tierSlug = tier as Exclude; - const priceId = STRIPE_PRICE_IDS[tierSlug]; + const cycle = (body.billing_cycle ?? 'annual') as BillingCycle; + if (!VALID_CYCLES.has(cycle)) { + return NextResponse.json({ error: 'Invalid billing_cycle (expected monthly or annual)' }, { status: 400 }); + } + + const tierPrices = STRIPE_PRICE_IDS[tierSlug]; + const priceId = tierPrices?.[cycle]; if (!priceId) { return NextResponse.json( - { error: 'Checkout not yet configured for this tier. Run scripts/stripe/sync-products.ts.' }, + { error: `Checkout not yet configured for tier=${tierSlug} cycle=${cycle}. Run scripts/stripe/sync-products.ts.` }, { status: 503 }, ); } @@ -61,8 +75,7 @@ export async function POST(req: NextRequest) { const stripe = getStripe(); const session = await stripe.checkout.sessions.create({ - mode: 'payment', - customer_creation: 'always', + mode: 'subscription', line_items: [ { price: priceId, @@ -74,8 +87,10 @@ export async function POST(req: NextRequest) { ], success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/pricing`, - metadata: { ngaf_tier_slug: tierSlug }, - payment_intent_data: { metadata: { ngaf_tier_slug: tierSlug } }, + metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle }, + subscription_data: { + metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle }, + }, }); if (!session.url) { diff --git a/apps/website/src/app/docs/licensing/page.tsx b/apps/website/src/app/docs/licensing/page.tsx new file mode 100644 index 00000000..5d67167e --- /dev/null +++ b/apps/website/src/app/docs/licensing/page.tsx @@ -0,0 +1,292 @@ +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { Container } from '../../../components/ui/Container'; +import { Section } from '../../../components/ui/Section'; +import { Eyebrow } from '../../../components/ui/Eyebrow'; +import { Button } from '../../../components/ui/Button'; +import { createPageMetadata } from '../../../lib/site-metadata'; + +export const metadata = createPageMetadata({ + title: 'Licensing — ThreadPlane', + description: + 'How the ThreadPlane Commercial license works, who needs one, and how to install your license token in @ngaf/chat.', + pathname: '/docs/licensing', + type: 'website', +}); + +const headingStyle = { + fontFamily: tokens.typography.h2.family, + fontSize: tokens.typography.h2.size, + lineHeight: tokens.typography.h2.line, + fontWeight: 700, + color: tokens.colors.textPrimary, + margin: 0, + marginBottom: 16, + letterSpacing: '-0.01em', +} as const; + +const h3Style = { + fontFamily: tokens.typography.h3.family, + fontSize: tokens.typography.h3.size, + lineHeight: tokens.typography.h3.line, + fontWeight: 600, + color: tokens.colors.textPrimary, + margin: 0, + marginTop: 24, + marginBottom: 8, +} as const; + +const bodyStyle = { + fontFamily: tokens.typography.body.family, + fontSize: tokens.typography.body.size, + lineHeight: tokens.typography.body.line, + color: tokens.colors.textSecondary, + margin: '0 0 16px', + maxWidth: '64ch', +} as const; + +const codeBlockStyle = { + fontFamily: tokens.typography.fontMono, + fontSize: 13, + lineHeight: 1.6, + background: tokens.surfaces.surfaceTinted, + border: `1px solid ${tokens.surfaces.border}`, + borderRadius: 8, + padding: 16, + overflow: 'auto', + color: tokens.colors.textPrimary, + margin: '0 0 16px', + whiteSpace: 'pre' as const, +} as const; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse' as const, + fontFamily: tokens.typography.body.family, + fontSize: 14, + color: tokens.colors.textSecondary, + margin: '0 0 24px', +} as const; + +const cellStyle = { + padding: '10px 12px', + borderBottom: `1px solid ${tokens.surfaces.border}`, + verticalAlign: 'top' as const, +} as const; + +const headerCellStyle = { + ...cellStyle, + color: tokens.colors.textPrimary, + fontWeight: 600, + background: tokens.surfaces.surfaceTinted, +} as const; + +export default function LicensingPage() { + return ( + <> +
+ +
+ Documentation +

+ Licensing +

+

+ How the ThreadPlane licensing model works, who needs a paid license, and how to install your license token. +

+
+
+
+ +
+ +
+

The model

+

+ Agent UI for Angular is a suite of libraries. Most are{' '} + MIT-licensed and free for any use, + commercial or not. Only @ngaf/chat is + dual-licensed. +

+

+ @ngaf/chat is source-available under{' '} + PolyForm Noncommercial 1.0.0 for free + noncommercial use, or a ThreadPlane Commercial license{' '} + for production use inside a for-profit context. The same source ships under both — you don't get a + different build. +

+ +

Do you need a paid license?

+

+ You need a ThreadPlane Commercial license if you use @ngaf/chat{' '} + in any of: +

+
    +
  • A commercial product or SaaS
  • +
  • An internal business tool inside a for-profit company
  • +
  • An agency deliverable or paid client project
  • +
  • Any application operated by or for a for-profit entity
  • +
+

You do not need a paid license for:

+
    +
  • Personal, hobby, student, academic, or nonprofit projects
  • +
  • Public demos and tutorials
  • +
  • Open-source applications released under an OSI-approved license
  • +
  • Commercial evaluation, up to 30 calendar days from your first commercial use
  • +
+
+
+
+ +
+ +
+

Install your license

+

+ After purchase, ThreadPlane emails a signed license token to the address on your receipt. Paste it + into your app's provideChat(){' '} + configuration: +

+
{`// app.config.ts
+import { ApplicationConfig } from '@angular/core';
+import { provideChat } from '@ngaf/chat';
+
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideChat({
+      license: process.env['THREADPLANE_LICENSE'],
+    }),
+  ],
+};`}
+

+ The library verifies the token's Ed25519 signature on boot. The check is{' '} + advisory-only: a missing, expired, or + tampered token logs a console.warn but + never blocks rendering. Verification is fully offline; no calls leave your app at runtime. +

+

+ The token is safe to commit to a private repository, or to read from a build-time environment variable + for public repos. Public-repo demos are exempt from the commercial-use definition, but if your + public repo backs a commercial product, the deployed bundle does need a license. +

+
+
+
+ +
+ +
+

Tier scoping

+

+ Pick the tier that matches how you'll deploy. All paid tiers grant the same{' '} + ThreadPlane Commercial license; the difference is the scope of use and the number of seats. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TierDevelopersBest for
Developer Seat — $29/dev/mo or $299/dev/yrPer seatSolo devs, growing teams
Team — $149/mo or $1,495/yr5 seats includedSmall teams that want a single SKU and renewal
Enterprise — from $4,000/moCustomSLA, security review, Pilot-to-Prod engagement, Slack Connect
+
+

+ Paid tiers are recurring subscriptions. Annual saves ~15% vs monthly. Cancel anytime — the license + stays valid through the end of the current paid period. +

+
+
+
+ +
+ +
+

Evaluation

+

+ You may use @ngaf/chat commercially + for 30 calendar days from your first + commercial use as a good-faith evaluation. There is no telemetry, no registration, no email check — + we trust you to count the days. After 30 days you must either purchase a license or stop the + commercial use. +

+ +

Refunds

+

+ If you refund a license through Stripe, the token is revoked automatically and we email a confirmation. + The verification check warns on boot. There's no clawback of the source code you already have — + everything is source-available under PolyForm Noncommercial by default. +

+ +

Questions

+

+ Volume pricing, multi-app licensing, audit clauses, custom terms — any of those, reach out and we'll + work it out. +

+
+ + + + Pricing FAQ → + +
+
+
+
+ + ); +} diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index de07919f..823c7ddd 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -2,7 +2,6 @@ import { tokens } from '@ngaf/design-tokens'; import { Container } from '../../components/ui/Container'; import { Section } from '../../components/ui/Section'; import { Eyebrow } from '../../components/ui/Eyebrow'; -import { PricingGrid } from '../../components/pricing/PricingGrid'; import { CompareTable } from '../../components/pricing/CompareTable'; import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix'; import { PricingFAQ } from '../../components/pricing/PricingFAQ'; @@ -13,33 +12,15 @@ import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ title: 'Pricing — Agent UI for Angular', description: - '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a Threadplane license. Other libraries remain MIT.', + '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a ThreadPlane Commercial license. Other libraries remain MIT.', pathname: '/pricing', type: 'website', }); -function SmallNote({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ); -} - export default function PricingPage() { return ( <> -
+
Pricing @@ -52,47 +33,17 @@ export default function PricingPage() { lineHeight: tokens.typography.h1.line, color: tokens.colors.textPrimary, margin: 0, - marginBottom: 16, letterSpacing: '-0.02em', }} > - Pricing for production AI chat interfaces + Simple, transparent pricing -

- @ngaf/chat is free for noncommercial use. Commercial production use requires a Threadplane license. Other libraries in the framework remain MIT. -

- - -
- - - A license is required when @ngaf/chat is used in a commercial product, SaaS app, internal business tool, paid client project, or production application operated by or for a for-profit entity. - - -
- -
- - - Commercial evaluation is free for 30 days. A paid license is required before production deployment. - - -
-
Compatibility @@ -123,14 +74,6 @@ export default function PricingPage() { -
- - - Because commercial use requires a license, @ngaf/chat is source-available rather than OSI open source. Threadplane keeps ecosystem packages (@ngaf/render, @ngaf/agent, @ngaf/langgraph, @ngaf/ag-ui, @ngaf/a2ui, @ngaf/licensing, @ngaf/telemetry, @ngaf/design-tokens) permissively MIT-licensed. - - -
- diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx index 1dd98470..cf2f5cb9 100644 --- a/apps/website/src/app/thanks/page.tsx +++ b/apps/website/src/app/thanks/page.tsx @@ -7,7 +7,7 @@ import { Button } from '../../components/ui/Button'; import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ - title: 'Payment received — Threadplane', + title: 'Payment received — ThreadPlane', description: 'Your @ngaf/chat license token will be emailed shortly.', pathname: '/thanks', type: 'website', @@ -57,8 +57,8 @@ export default function ThanksPage() { If you don't see the email within 10 minutes, check spam or contact us.

- + + ); + } + return ( + + ); +} + +const LABEL_COL_WIDTH = '22%'; + +function BillingToggle({ + cycle, + setCycle, + discountPct, +}: { + cycle: BillingCycle; + setCycle: (c: BillingCycle) => void; + discountPct: number; +}) { + const baseBtn = { + fontFamily: tokens.typography.fontSans, + fontSize: 13, + fontWeight: 600, + padding: '8px 16px', + border: 'none', + cursor: 'pointer', + transition: 'background 150ms ease, color 150ms ease', + background: 'transparent', + color: tokens.colors.textSecondary, + borderRadius: 999, + } as const; + const activeBtn = { + ...baseBtn, + background: tokens.colors.accent, + color: '#fff', + } as const; + return ( +
+
+ + +
+
+ ); +} + +function SectionTable({ + title, + rows, + cycle, + showPrice, +}: { + title: string; + rows: FeatureRow[]; + cycle: BillingCycle; + showPrice: boolean; +}) { return ( -
+
- +
- + - {TIERS.map((t) => ( + {TIERS.map((tier) => ( ))} + {showPrice ? ( + + + {TIERS.map((tier) => { + const p = tier.prices[cycle]; + return ( + + ); + })} + + ) : ( + + + )} - {ROWS.map((row) => ( + {rows.map((row, i) => ( (e.currentTarget.style.background = tokens.colors.accentSurface)} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + style={{ + borderBottom: i === rows.length - 1 ? 'none' : `1px solid ${tokens.surfaces.border}`, + }} > - - {TIERS.map((t) => ( - ))} @@ -121,6 +436,67 @@ export function CompareTable() {
- Feature + {title} - {t.label} + {tier.highlight && ( +
+ MOST POPULAR +
+ )} +
+ {tier.name} +
+ Price + +
+ + {p.display} + + {p.period && ( + + {p.period} + + )} +
+
+ {TIERS.map((tier) => ( + + ))} +
+ {row.feature} - {renderCell(row.cells[t.key])} + {TIERS.map((tier) => ( + + {renderCell(row.cells[tier.slug])}
+
+ ); +} + +function CtaStrip({ cycle }: { cycle: BillingCycle }) { + return ( +
+
+ {TIERS.map((tier) => ( +
+
+ +
+
+ ))} +
+ ); +} + +export function CompareTable() { + const [cycle, setCycle] = useState('annual'); + const discountPct = annualDiscountPercent(); + + return ( +
+ + + +
+ +
+ +
+ + +
+ +
); } diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx index ea604aae..1e23d715 100644 --- a/apps/website/src/components/pricing/LeadForm.tsx +++ b/apps/website/src/components/pricing/LeadForm.tsx @@ -9,8 +9,30 @@ import { Eyebrow } from '../ui/Eyebrow'; import { Button } from '../ui/Button'; import { Card } from '../ui/Card'; +const VALUE_PROPS = [ + { + title: 'ThreadPlane Commercial license', + body: 'Multi-app coverage, unlimited developers, custom contract — built for procurement.', + }, + { + title: 'SLA + security review', + body: 'Response SLAs, security questionnaires, and a private support channel.', + }, + { + title: 'Pilot-to-Prod engagement', + body: '8-week concierge delivery. We ship your first Angular agent on your real data, in your real app — and your engineers own it at the end.', + highlight: true, + link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' }, + }, + { + title: 'Procurement support', + body: 'Master services agreement, security review, custom indemnification — handled by humans, not portals.', + }, +]; + export function LeadForm() { const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -25,7 +47,7 @@ export function LeadForm() { const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + body: JSON.stringify({ ...data, pilot_interest: pilotInterest }), }); if (res.ok) { track(analyticsEvents.marketingLeadFormSuccess, { @@ -63,11 +85,11 @@ export function LeadForm() { outline: 'none', }; - const handleFocus = (e: React.FocusEvent) => { + const handleFocus = (e: React.FocusEvent) => { e.target.style.borderColor = tokens.colors.accent; e.target.style.boxShadow = tokens.shadows.focus; }; - const handleBlur = (e: React.FocusEvent) => { + const handleBlur = (e: React.FocusEvent) => { e.target.style.borderColor = tokens.surfaces.border; e.target.style.boxShadow = 'none'; }; @@ -75,91 +97,302 @@ export function LeadForm() { return (
-
- Enterprise -

+
+ Enterprise +

+ Built for procurement.
Backed by delivery. +

+

+ Volume licensing, custom contract, and optional concierge delivery — so your first Angular agent ships, not just compiles. +

+
+ +
- Need volume seats or a custom contract? -

- {status === 'sent' ? ( -

- Thanks — we'll be in touch within one business day. -

- ) : ( - -
- - - - - - - -