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
85 changes: 72 additions & 13 deletions apps/web/src/app/(app)/settings/billing/_components/plan-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ interface Props {
highlightSlug?: string | null;
}

const SLUG_ORDER = ['free', 'starter', 'team', 'business'];
const PURCHASABLE = new Set(['starter', 'team', 'business']);
const SLUG_ORDER = ['free', 'starter', 'team', 'scale', 'business'];
const PURCHASABLE = new Set(['starter', 'team', 'scale', 'business']);
const POPULAR_SLUG = 'team';

function formatCredits(n: number): string {
Expand All @@ -33,15 +33,20 @@ export function PlanGrid({ plans, currentSlug, highlightSlug }: Props) {
);
const currentIndex = currentSlug ? SLUG_ORDER.indexOf(currentSlug) : -1;
const [busy, setBusy] = useState<string | null>(null);
const [interval, setInterval] = useState<'monthly' | 'annual'>('annual');
const [, startTransition] = useTransition();

const showAnnualToggle = ordered.some(
(p) => p.annualPriceCents !== null && p.annualPriceCents !== undefined,
);

async function startCheckout(planSlug: string) {
setBusy(planSlug);
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planSlug }),
body: JSON.stringify({ planSlug, billingInterval: interval }),
});
const body = (await res.json().catch(() => ({}))) as {
url?: string;
Expand All @@ -63,8 +68,46 @@ export function PlanGrid({ plans, currentSlug, highlightSlug }: Props) {

return (
<section id="plans" className="space-y-3 scroll-mt-8">
<h3 className="text-[15px] font-medium text-text">Plans</h3>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-medium text-text">Plans</h3>
{showAnnualToggle ? (
<div
role="tablist"
aria-label="Billing interval"
className="inline-flex rounded-md border border-border bg-surface p-0.5"
>
<button
type="button"
role="tab"
aria-selected={interval === 'monthly'}
onClick={() => setInterval('monthly')}
className={[
'h-7 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors',
interval === 'monthly'
? 'bg-surface-2 text-text'
: 'text-text-muted hover:text-text',
].join(' ')}
>
Monthly
</button>
<button
type="button"
role="tab"
aria-selected={interval === 'annual'}
onClick={() => setInterval('annual')}
className={[
'h-7 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors',
interval === 'annual'
? 'bg-surface-2 text-text'
: 'text-text-muted hover:text-text',
].join(' ')}
>
Annual <span className="ml-1 text-[10.5px] text-accent">−15%</span>
</button>
</div>
) : null}
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{ordered.map((plan) => {
const isCurrent = currentSlug === plan.slug;
const isHighlight = highlightSlug === plan.slug;
Expand Down Expand Up @@ -94,14 +137,30 @@ export function PlanGrid({ plans, currentSlug, highlightSlug }: Props) {
</span>
) : null}
</div>
<div className="mt-3 font-mono text-[28px] leading-none tabular-nums text-text">
{formatPrice(plan.monthlyPriceCents)}
{plan.monthlyPriceCents > 0 ? (
<span className="ml-1 text-[13px] font-normal text-text-muted">
/mo
</span>
) : null}
</div>
{(() => {
const onAnnual =
interval === 'annual' && plan.annualPriceCents !== null && plan.annualPriceCents > 0;
const displayCents = onAnnual
? Math.round((plan.annualPriceCents as number) / 12)
: plan.monthlyPriceCents;
return (
<>
<div className="mt-3 font-mono text-[28px] leading-none tabular-nums text-text">
{formatPrice(displayCents)}
{displayCents > 0 ? (
<span className="ml-1 text-[13px] font-normal text-text-muted">
{onAnnual ? '/mo billed annually' : '/mo'}
</span>
) : null}
</div>
{onAnnual ? (
<p className="mt-1 text-[11.5px] text-text-subtle tabular-nums">
${((plan.annualPriceCents as number) / 100).toLocaleString('en-US')} / year
</p>
) : null}
</>
);
})()}
<ul className="mt-5 mb-6 space-y-2 text-[13px] text-text-muted">
<li className="flex gap-2">
<Check className="h-4 w-4 shrink-0 text-text-subtle" aria-hidden />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/airtable/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'airtable');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/asana/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'asana');
const userId = session.user.id;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'confluence');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/grain/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'grain');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/hubspot/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'hubspot');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/jira/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'jira');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/linear/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'linear');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/mintlify/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'mintlify');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/prismic/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export async function POST(req: Request) {
const name = `${repo}.prismic.io`;
const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'prismic');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/pylon/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'pylon');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/stripe/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'stripe');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/teams/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function POST() {
}
const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'teams');
const userId = session.user.id;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/webcrawl/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function POST(req: Request) {
const orgId = resolveActiveOrgId(session);
const userId = session.user.id;

// Plan-limit gate (free → 1 connector). No-op for re-auth and self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and self-hosted CE.
await enforceConnectorLimit(db, orgId, 'webcrawl');

// Validate every URL up front. assertPublicHttpUrl resolves DNS and
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/connectors/zendesk/connect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export async function POST(req: Request) {

const orgId = resolveActiveOrgId(session);

// Plan-limit gate (free → 1 connector). No-op for re-auth and for self-hosted CE.
// Plan-limit gate (free → 2 connectors). No-op for re-auth and for self-hosted CE.
await enforceConnectorLimit(db, orgId, 'zendesk');
const userId = session.user.id;

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const bodySchema = z.object({
planSlug: z.enum(['starter', 'team', 'business']),
planSlug: z.enum(['starter', 'team', 'scale', 'business']),
billingInterval: z.enum(['monthly', 'annual']).default('monthly'),
});

/**
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
throw holoError({
code: ErrorCode.HOLO_INVALID_INPUT,
problem: `invalid request: ${parsed.error.message}`,
fix: 'Send { planSlug: "starter" | "team" | "business" }.',
fix: 'Send { planSlug: "starter" | "team" | "scale" | "business", billingInterval?: "monthly" | "annual" }.',
});
}

Expand All @@ -65,6 +66,7 @@ export async function POST(req: Request): Promise<Response> {
db,
organizationId,
planSlug: parsed.data.planSlug,
billingInterval: parsed.data.billingInterval,
ownerEmail,
successUrl: `${origin}/settings/billing?checkout=success`,
cancelUrl: `${origin}/settings/billing?checkout=cancel`,
Expand All @@ -77,6 +79,7 @@ export async function POST(req: Request): Promise<Response> {
surface: 'web',
kind: 'subscription',
plan_slug: parsed.data.planSlug,
billing_interval: parsed.data.billingInterval,
checkout_session_id: result.sessionId,
},
});
Expand Down
Loading
Loading