Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ NEXT_PUBLIC_COCKPIT_INGEST_HOST=
# Production: https://examples.threadplane.ai
# Leave empty in dev — wildcard '*' is used.
NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN=

# Stripe — see scripts/stripe/sync-products.ts and src/app/api/checkout/session/route.ts
STRIPE_SECRET_KEY=sk_test_…
3 changes: 3 additions & 0 deletions apps/website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ RESEND_NOTIFY_TO=hello@cacheplane.ai

# Loops.so (https://loops.so — free tier: 1,000 contacts)
LOOPS_API_KEY=

# Stripe — see scripts/stripe/sync-products.ts and src/app/api/checkout/session/route.ts
STRIPE_SECRET_KEY=sk_test_…
8 changes: 1 addition & 7 deletions apps/website/content/docs/agent/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1447,7 +1447,7 @@
{
"name": "LangGraphThreadsConfig",
"kind": "interface",
"description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n titleMetadataKey: 'thread_title',\n }},\n],\n```",
"description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n }},\n],\n```\n\nThe adapter expects backends to write the thread title to\n`metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md\noriginally proposed `metadata.thread_title` for cockpit caps but\nwe converged on `title` to match the canonical demo and avoid a\nper-cap configuration knob.",
"properties": [
{
"name": "apiUrl",
Expand All @@ -1460,12 +1460,6 @@
"type": "string",
"description": "Fallback label for threads whose title hasn't been written yet\n (e.g. created but never sent). Defaults to `'Untitled'`.",
"optional": true
},
{
"name": "titleMetadataKey",
"type": "string",
"description": "Metadata key the backend writes the thread title to. Two\n conventions exist in the wild:\n - `'title'` — legacy / canonical demo\n - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design\n Defaults to `'thread_title'`.",
"optional": true
}
],
"examples": []
Expand Down
83 changes: 83 additions & 0 deletions apps/website/src/app/api/checkout/session/route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';

const stripeCreate = vi.fn();

vi.mock('../../../../lib/stripe', () => ({
getStripe: () => ({ checkout: { sessions: { create: stripeCreate } } }),
}));

vi.mock('../../../../../../../pricing/tiers.generated', () => ({
STRIPE_PRICE_IDS: {
indie: 'price_test_indie',
developer_seat: 'price_test_seat',
app_deployment: 'price_test_app',
},
}));

import { POST } from './route';

function makeReq(body: unknown): NextRequest {
return new NextRequest('http://localhost:3000/api/checkout/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}

describe('POST /api/checkout/session', () => {
beforeEach(() => {
stripeCreate.mockReset();
stripeCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/c/pay/cs_test_abc' });
});

it('returns 400 for unknown tier', async () => {
const res = await POST(makeReq({ tier: 'bogus' }));
expect(res.status).toBe(400);
});

it('returns 400 for community tier (not Stripe-buyable)', async () => {
const res = await POST(makeReq({ tier: 'community' }));
expect(res.status).toBe(400);
});

it('returns 400 for enterprise tier (not Stripe-buyable)', async () => {
const res = await POST(makeReq({ tier: 'enterprise' }));
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);
const args = stripeCreate.mock.calls[0]?.[0];
expect(args.mode).toBe('payment');
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');
});

it('enables adjustable_quantity only for developer_seat', async () => {
await POST(makeReq({ tier: 'developer_seat', quantity: 3 }));
const args = stripeCreate.mock.calls[0]?.[0];
expect(args.line_items[0].quantity).toBe(3);
expect(args.line_items[0].adjustable_quantity).toEqual({ enabled: true, minimum: 1, maximum: 100 });
});

it('clamps quantity to [1, 100]', async () => {
await POST(makeReq({ tier: 'developer_seat', quantity: 9999 }));
expect(stripeCreate.mock.calls[0]?.[0].line_items[0].quantity).toBe(100);

stripeCreate.mockClear();
await POST(makeReq({ tier: 'developer_seat', quantity: 0 }));
expect(stripeCreate.mock.calls[0]?.[0].line_items[0].quantity).toBe(1);
});

it('returns 502 if Stripe returns no URL', async () => {
stripeCreate.mockResolvedValueOnce({ url: null });
const res = await POST(makeReq({ tier: 'indie' }));
expect(res.status).toBe(502);
});
});
85 changes: 85 additions & 0 deletions apps/website/src/app/api/checkout/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
import { NextRequest, NextResponse } from 'next/server';
import { getStripe } from '../../../../lib/stripe';
import { TIERS, type TierSlug } from '../../../../../../../pricing/tiers.config';
import { STRIPE_PRICE_IDS } from '../../../../../../../pricing/tiers.generated';

const BUYABLE_SLUGS = new Set<TierSlug>(['indie', 'developer_seat', 'app_deployment']);

interface RequestBody {
tier?: string;
quantity?: number;
}

function getOrigin(req: NextRequest): string {
const forwardedHost = req.headers.get('x-forwarded-host');
const host = forwardedHost ?? req.headers.get('host') ?? 'localhost:3000';
const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
return `${proto}://${host}`;
}

export async function POST(req: NextRequest) {
let body: RequestBody;
const contentType = req.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
} else {
const form = await req.formData();
body = {
tier: typeof form.get('tier') === 'string' ? (form.get('tier') as string) : undefined,
quantity: form.get('quantity') ? Number(form.get('quantity')) : undefined,
};
}

const tier = body.tier;
if (typeof tier !== 'string' || !BUYABLE_SLUGS.has(tier as TierSlug)) {
return NextResponse.json({ error: 'Invalid or unbuyable tier' }, { status: 400 });
}
const tierSlug = tier as Exclude<TierSlug, 'community' | 'enterprise'>;

const priceId = STRIPE_PRICE_IDS[tierSlug];
if (!priceId) {
return NextResponse.json(
{ error: 'Checkout not yet configured for this tier. Run scripts/stripe/sync-products.ts.' },
{ status: 503 },
);
}

const tierConfig = TIERS.find((t) => t.slug === tierSlug);
if (!tierConfig) {
return NextResponse.json({ error: 'Tier missing from config' }, { status: 500 });
}

const rawQuantity = body.quantity ?? tierConfig.defaultQuantity ?? 1;
const quantity = Math.max(1, Math.min(100, Math.floor(rawQuantity)));

const origin = getOrigin(req);
const stripe = getStripe();

const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price: priceId,
quantity,
...(tierConfig.adjustableQuantity
? { adjustable_quantity: { enabled: true, minimum: 1, maximum: 100 } }
: {}),
},
],
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 } },
});

if (!session.url) {
return NextResponse.json({ error: 'Stripe did not return a checkout URL' }, { status: 502 });
}

return NextResponse.redirect(session.url, { status: 303 });
}
40 changes: 40 additions & 0 deletions apps/website/src/app/thanks/page.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
// @vitest-environment jsdom
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import ThanksPage from './page';

vi.mock('../../components/ui/Container', () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../../components/ui/Section', () => ({
Section: ({ children }: { children: React.ReactNode }) => <section>{children}</section>,
}));
vi.mock('../../components/ui/Eyebrow', () => ({
Eyebrow: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../../components/ui/Button', () => ({
Button: ({ children, href }: { children: React.ReactNode; href?: string }) =>
<a href={href}>{children}</a>,
}));

describe('ThanksPage', () => {
it('renders the payment-received heading', () => {
render(<ThanksPage />);
expect(screen.getByRole('heading', { level: 1, name: 'Thanks for your purchase.' })).toBeTruthy();
});

it('mentions provideChat() activation', () => {
render(<ThanksPage />);
expect(screen.getByText(/provideChat\(\)/)).toBeTruthy();
});

it('links to installation docs and contact', () => {
render(<ThanksPage />);
expect(screen.getByRole('link', { name: 'Installation docs' }).getAttribute('href'))
.toBe('/docs/chat/getting-started/installation');
expect(screen.getByRole('link', { name: 'Contact support' }).getAttribute('href'))
.toBe('/contact');
});
});
71 changes: 71 additions & 0 deletions apps/website/src/app/thanks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
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: 'Payment received — Threadplane',
description: 'Your @ngaf/chat license token will be emailed shortly.',
pathname: '/thanks',
type: 'website',
});

export default function ThanksPage() {
return (
<Section surface="canvas" ariaLabelledBy="thanks-heading">
<Container>
<div style={{ textAlign: 'center', maxWidth: 640, margin: '0 auto' }}>
<Eyebrow tone="accent" style={{ marginBottom: 16 }}>Payment received</Eyebrow>
<h1
id="thanks-heading"
style={{
fontFamily: tokens.typography.h1.family,
fontWeight: 700,
fontSize: tokens.typography.h1.size,
lineHeight: tokens.typography.h1.line,
color: tokens.colors.textPrimary,
margin: 0,
marginBottom: 16,
letterSpacing: '-0.02em',
}}
>
Thanks for your purchase.
</h1>
<p
style={{
fontFamily: tokens.typography.bodyLg.family,
fontSize: tokens.typography.bodyLg.size,
lineHeight: tokens.typography.bodyLg.line,
color: tokens.colors.textSecondary,
margin: '0 auto 24px',
}}
>
Your <code style={{ fontFamily: tokens.typography.fontMono }}>@ngaf/chat</code> license token will be emailed to the address on your receipt within a few minutes. Paste it into your app's <code style={{ fontFamily: tokens.typography.fontMono }}>provideChat()</code> config to activate.
</p>
<p
style={{
fontFamily: tokens.typography.body.family,
fontSize: 13,
lineHeight: 1.6,
color: tokens.colors.textMuted,
margin: '0 auto 32px',
}}
>
If you don't see the email within 10 minutes, check spam or contact us.
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
<Button variant="primary" size="md" href="/docs/chat/getting-started/installation">
Installation docs
</Button>
<Button variant="ghost" size="md" href="/contact">
Contact support
</Button>
</div>
</div>
</Container>
</Section>
);
}
Loading
Loading