Skip to content

Commit 425c113

Browse files
committed
fix: purchasable Premium tier (plan picker), onboarding automations link, composio trigger pause/resume
1 parent efc610a commit 425c113

6 files changed

Lines changed: 238 additions & 100 deletions

File tree

apps/gateway-worker/src/automations-routes.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
import { Cron } from "croner";
2929
import { z } from "zod";
3030
import { requireVerifiedClerkEmail } from "./authenticate";
31-
import { deleteAutomationTrigger, registerAutomationTrigger } from "./automation-triggers";
31+
import {
32+
deleteAutomationTrigger,
33+
registerAutomationTrigger,
34+
setAutomationTriggerState,
35+
} from "./automation-triggers";
3236
import type { GatewayEnv } from "./index";
3337
import { enforceActiveProjectLimit } from "./limits";
3438

@@ -208,7 +212,10 @@ export async function updateAutomationRoute(
208212
const patch = parsed.data;
209213
const { db, close } = createDb(env.HYPERDRIVE);
210214
try {
211-
const summary = await withUserContext(db, userId, async (tx) => {
215+
// Pausing/resuming an EVENT automation must also disable/enable its Composio
216+
// trigger so a paused automation stops receiving webhook deliveries (and a
217+
// resumed one starts again). The intent is returned from the tx, applied after.
218+
const { summary, triggerSync } = await withUserContext(db, userId, async (tx) => {
212219
const existing = await getAutomation(tx, userId, id);
213220
if (!existing) {
214221
throw notFound("Automation not found");
@@ -218,8 +225,25 @@ export async function updateAutomationRoute(
218225
if (!row) {
219226
throw notFound("Automation not found");
220227
}
221-
return automationToSummary(row);
228+
const shouldSync =
229+
existing.kind === "event" &&
230+
Boolean(existing.triggerId) &&
231+
patch.status !== undefined &&
232+
patch.status !== existing.status;
233+
return {
234+
summary: automationToSummary(row),
235+
triggerSync:
236+
shouldSync && existing.triggerId
237+
? {
238+
state: (patch.status === "running" ? "enable" : "disable") as "disable" | "enable",
239+
triggerId: existing.triggerId,
240+
}
241+
: null,
242+
};
222243
});
244+
if (triggerSync) {
245+
await setAutomationTriggerState(env, triggerSync.triggerId, triggerSync.state);
246+
}
223247
return Response.json(AutomationSummarySchema.parse(summary));
224248
} finally {
225249
ctx.waitUntil(close());
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"use client";
2+
3+
import type { PlanSummary } from "@cheatcode/types";
4+
import { PaidBillingTierSchema } from "@cheatcode/types";
5+
import { ModalShell } from "@cheatcode/ui";
6+
import { useMutation } from "@tanstack/react-query";
7+
import { toast } from "sonner";
8+
import { Loader2 } from "@/components/ui/icons";
9+
import { requestCheckout } from "@/lib/api/billing";
10+
import { useBillingCatalogQuery } from "@/lib/hooks/use-billing";
11+
12+
/**
13+
* Plan picker for upgrading. Lists every PAID tier from the billing catalog; a tier
14+
* is purchasable only when the catalog marks it `available` (i.e. its Polar product
15+
* id is configured), so Pro/Premium check out while Ultra/Max show "Coming soon"
16+
* until the owner creates their products. Checkout passes the tier the user picked
17+
* — no surface hardcodes a single tier.
18+
*/
19+
export function UpgradeDialog({
20+
getToken,
21+
onClose,
22+
open,
23+
}: {
24+
getToken: () => Promise<null | string>;
25+
onClose: () => void;
26+
open: boolean;
27+
}) {
28+
const catalogQuery = useBillingCatalogQuery(getToken);
29+
const checkoutMutation = useMutation({
30+
mutationFn: (tier: PlanSummary["id"]) =>
31+
requestCheckout(getToken, {
32+
returnUrl: window.location.href,
33+
successUrl: window.location.href,
34+
tier: PaidBillingTierSchema.parse(tier),
35+
}),
36+
onError: (error) => toast.error(error instanceof Error ? error.message : "Checkout failed"),
37+
onSuccess: (url) => window.location.assign(url),
38+
});
39+
40+
const paidPlans = (catalogQuery.data?.plans ?? []).filter((plan) => plan.id !== "free");
41+
42+
return (
43+
<ModalShell
44+
ariaLabel="Choose a plan"
45+
className="m-auto w-full max-w-lg"
46+
onClose={onClose}
47+
open={open}
48+
>
49+
<div className="flex flex-col gap-4 p-5 text-[#1b1b1b]">
50+
<div>
51+
<h2 className="font-semibold text-[18px]">Choose a plan</h2>
52+
<p className="mt-1 text-[#5f5f5f] text-[14px]">
53+
Sandbox hours are billed monthly. Provider inference stays bring-your-own-key.
54+
</p>
55+
</div>
56+
<UpgradeDialogBody
57+
isLoading={catalogQuery.isLoading}
58+
onChoose={(tier) => checkoutMutation.mutate(tier)}
59+
paidPlans={paidPlans}
60+
pendingTier={checkoutMutation.isPending ? (checkoutMutation.variables ?? null) : null}
61+
/>
62+
</div>
63+
</ModalShell>
64+
);
65+
}
66+
67+
function UpgradeDialogBody({
68+
isLoading,
69+
onChoose,
70+
paidPlans,
71+
pendingTier,
72+
}: {
73+
isLoading: boolean;
74+
onChoose: (tier: PlanSummary["id"]) => void;
75+
paidPlans: PlanSummary[];
76+
pendingTier: PlanSummary["id"] | null;
77+
}) {
78+
if (isLoading) {
79+
return <p className="py-6 text-center text-[#a0a0a0] text-[14px]">Loading plans…</p>;
80+
}
81+
if (paidPlans.length === 0) {
82+
return (
83+
<p className="py-6 text-center text-[#a0a0a0] text-[14px]">
84+
Plans are temporarily unavailable.
85+
</p>
86+
);
87+
}
88+
return (
89+
<ul className="flex flex-col gap-2">
90+
{paidPlans.map((plan) => (
91+
<PlanRow
92+
isPending={pendingTier === plan.id}
93+
key={plan.id}
94+
onChoose={() => onChoose(plan.id)}
95+
plan={plan}
96+
/>
97+
))}
98+
</ul>
99+
);
100+
}
101+
102+
function PlanRow({
103+
isPending,
104+
onChoose,
105+
plan,
106+
}: {
107+
isPending: boolean;
108+
onChoose: () => void;
109+
plan: PlanSummary;
110+
}) {
111+
const purchasable = plan.available && !plan.current;
112+
return (
113+
<li className="flex items-center justify-between gap-3 rounded-[16px] border border-[#ececec] bg-white px-4 py-3">
114+
<div className="min-w-0">
115+
<p className="font-medium text-[#1b1b1b] text-[15px]">
116+
{plan.displayName}
117+
{plan.current ? " · current" : ""}
118+
</p>
119+
<p className="text-[#8a8a8a] text-[12px]">
120+
${plan.monthlyPriceUsd}/mo · {plan.sandboxHoursPerMonth} sandbox-hours
121+
</p>
122+
</div>
123+
{purchasable ? (
124+
<button
125+
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-[#1b1b1b] px-4 font-medium text-[14px] text-white transition-colors hover:bg-black disabled:opacity-50"
126+
disabled={isPending}
127+
onClick={onChoose}
128+
type="button"
129+
>
130+
{isPending ? <Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" /> : null}
131+
Choose
132+
</button>
133+
) : (
134+
<span className="shrink-0 rounded-full border border-[#f1f1f1] px-3 py-1.5 text-[#a0a0a0] text-[12px]">
135+
{plan.current ? "Current" : "Coming soon"}
136+
</span>
137+
)}
138+
</li>
139+
);
140+
}

apps/web/src/components/home/home-composer.tsx

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
SandboxUsageSummaryResponseSchema,
99
} from "@cheatcode/types";
1010
import { useAuth } from "@clerk/nextjs";
11-
import { useMutation } from "@tanstack/react-query";
1211
import { useRouter } from "next/navigation";
1312
import {
1413
type ChangeEvent,
@@ -22,6 +21,7 @@ import {
2221
useState,
2322
} from "react";
2423
import { toast } from "sonner";
24+
import { UpgradeDialog } from "@/components/billing/upgrade-dialog";
2525
import { AddMenu } from "@/components/composer/add-menu";
2626
import {
2727
ComposerContextChips,
@@ -37,18 +37,8 @@ import {
3737
} from "@/components/composer/use-composer-triggers";
3838
import { resolveInitialSkill, skillSurface } from "@/components/home/use-initial-skill";
3939
import { CheatcodeMark } from "@/components/ui/cheatcode-mark";
40-
import {
41-
ArrowUp,
42-
Globe,
43-
Loader2,
44-
Smartphone,
45-
Star,
46-
TrendingUp,
47-
X,
48-
Zap,
49-
} from "@/components/ui/icons";
40+
import { ArrowUp, Globe, Smartphone, Star, TrendingUp, X, Zap } from "@/components/ui/icons";
5041
import { agentModelRequestValue } from "@/lib/agent-models";
51-
import { requestCheckout } from "@/lib/api/billing";
5242
import { buildExistingProjectParams, launchIntoProject } from "@/lib/api/home-launch";
5343
import { detectSlashToken } from "@/lib/input/caret-tokens";
5444
import {
@@ -558,20 +548,7 @@ async function resolveComposerAuthToken(
558548

559549
function FreePlanComposerBanner({ getToken }: { getToken: () => Promise<null | string> }) {
560550
const tier = useComposerUsageTier();
561-
const checkoutMutation = useMutation({
562-
mutationFn: () =>
563-
requestCheckout(getToken, {
564-
returnUrl: window.location.href,
565-
successUrl: window.location.href,
566-
tier: "pro",
567-
}),
568-
onError: (error) => {
569-
toast.error(error instanceof Error ? error.message : "Checkout failed");
570-
},
571-
onSuccess: (url) => {
572-
window.location.assign(url);
573-
},
574-
});
551+
const [pickerOpen, setPickerOpen] = useState(false);
575552

576553
if (tier && tier !== "free") {
577554
return null;
@@ -588,23 +565,16 @@ function FreePlanComposerBanner({ getToken }: { getToken: () => Promise<null | s
588565
</span>
589566
<button
590567
className="shrink-0 font-medium text-[#5f5f5f] text-[13px] leading-[19.5px] transition-colors hover:text-[#1b1b1b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1b1b1b]/15 focus-visible:ring-offset-2"
591-
disabled={checkoutMutation.isPending}
592-
onClick={() => checkoutMutation.mutate()}
568+
onClick={() => setPickerOpen(true)}
593569
type="button"
594570
>
595-
{checkoutMutation.isPending ? (
596-
<span className="inline-flex items-center gap-1.5">
597-
<Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" />
598-
Opening
599-
</span>
600-
) : (
601-
"Select a plan"
602-
)}
571+
Select a plan
603572
</button>
604573
</div>
605574
</div>
606575
</div>
607576
</div>
577+
<UpgradeDialog getToken={getToken} onClose={() => setPickerOpen(false)} open={pickerOpen} />
608578
</div>
609579
);
610580
}

apps/web/src/components/onboarding/onboarding-flow.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
type OnboardingStep,
55
OnboardingStepSchema,
66
type OnboardingStepStatus,
7+
type PaidBillingTier,
8+
PaidBillingTierSchema,
9+
type PlanSummary,
710
type UpdateUserProfile,
811
type UserProfile,
912
} from "@cheatcode/types";
@@ -26,15 +29,28 @@ import { useProfileQuery, useUpdateProfileMutation } from "@/lib/hooks/use-profi
2629

2730
type Phase = "finishing" | "loading" | "retry" | "stepping";
2831

32+
/** The set of paid tiers the catalog reports as purchasable right now (a tier is
33+
* unavailable until its Polar product id is configured). */
34+
function availablePaidTiers(plans: PlanSummary[] | undefined): ReadonlySet<PaidBillingTier> {
35+
const tiers = new Set<PaidBillingTier>();
36+
for (const plan of plans ?? []) {
37+
const paid = PaidBillingTierSchema.safeParse(plan.id);
38+
if (paid.success && plan.available) {
39+
tiers.add(paid.data);
40+
}
41+
}
42+
return tiers;
43+
}
44+
2945
const STEP_ORDER = OnboardingStepSchema.options;
3046

3147
interface StepProps {
32-
canCheckout: boolean;
48+
availableTiers: ReadonlySet<PaidBillingTier>;
3349
initialName: string;
3450
isBusy: boolean;
3551
onBasicsContinue: () => void;
3652
onBasicsSkip: () => void;
37-
onCheckout: () => void;
53+
onCheckout: (tier: PaidBillingTier) => void;
3854
onIntro: () => void;
3955
onNameContinue: (name: string) => void;
4056
onNameSkip: () => void;
@@ -61,8 +77,7 @@ export function OnboardingFlow() {
6177
const { mutateAsync } = mutation;
6278
const checkoutMutation = useCheckoutMutation(getToken);
6379
const catalogQuery = useBillingCatalogQuery(getToken);
64-
const canCheckout =
65-
catalogQuery.data?.plans.some((plan) => plan.id === "pro" && plan.available) ?? false;
80+
const availableTiers = availablePaidTiers(catalogQuery.data?.plans);
6681

6782
const completeOnboarding = useCallback(
6883
async (target: string, planStatus: OnboardingStepStatus = "done") => {
@@ -147,7 +162,7 @@ export function OnboardingFlow() {
147162
}
148163

149164
const stepProps: StepProps = {
150-
canCheckout,
165+
availableTiers,
151166
initialName: profile?.agentDisplayName ?? "",
152167
isBusy: checkoutMutation.isPending,
153168
onBasicsContinue: () => {
@@ -158,7 +173,7 @@ export function OnboardingFlow() {
158173
recordStep("basics", "skipped");
159174
advance();
160175
},
161-
onCheckout: () => checkoutMutation.mutate(),
176+
onCheckout: (tier) => checkoutMutation.mutate(tier),
162177
onIntro: () => {
163178
recordStep("intro", "done");
164179
advance();
@@ -209,7 +224,7 @@ function renderStep(stepName: OnboardingStep, props: StepProps): ReactNode {
209224
case "plan":
210225
return (
211226
<PlanStep
212-
canCheckout={props.canCheckout}
227+
availableTiers={props.availableTiers}
213228
isBusy={props.isBusy}
214229
onCheckout={props.onCheckout}
215230
onComplete={props.onPlanComplete}
@@ -222,13 +237,13 @@ function renderStep(stepName: OnboardingStep, props: StepProps): ReactNode {
222237

223238
function useCheckoutMutation(getToken: () => Promise<null | string>) {
224239
return useMutation({
225-
mutationFn: () =>
240+
mutationFn: (tier: PaidBillingTier) =>
226241
requestCheckout(getToken, {
227242
returnUrl: window.location.href,
228243
// Marker so the onboarding flow auto-completes on return instead of
229244
// dropping the buyer back on the Plan step.
230245
successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
231-
tier: "pro",
246+
tier,
232247
}),
233248
onError: (error) => {
234249
toast.error(error instanceof Error ? error.message : "Checkout failed");

0 commit comments

Comments
 (0)