[eric] free-trial: value-first onboarding reorder, arm-on-launch, exhaustion upsell card

This commit is contained in:
ciregenz
2026-06-10 00:01:37 -07:00
parent fb178648df
commit c8c95e4297
9 changed files with 59 additions and 13 deletions
+10 -1
View File
@@ -232,7 +232,16 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => {});
.catch(() => {})
.finally(() => {
// Arm the zero-config free trial when nothing is connected so a brand-new
// user can run an agent immediately. The backend no-ops if a real key or
// subscription exists, so this is safe to fire on every launch.
fetch(`${API_BASE}/subscription/free-trial/mint`, { method: 'POST' })
.then((r) => (r.ok ? r.json() : null))
.then((data) => { if (data && data.armed) dispatch(fetchSettings()); })
.catch(() => {});
});
}, [dispatch]);
useEffect(() => {
@@ -8,10 +8,13 @@ import { step06 } from './step06_agentControlAgents';
import { step07 } from './step07_installSkill';
import { step08 } from './step08_makeApp';
// Value-first order: launch an agent (step03) FIRST so a brand-new user sees
// the product work on the free trial, then connect-your-own-model (step01).
// Everything else is "learn the features", revealed after the first win.
export const STEPS: OnboardingStep[] = [
step03,
step01,
step02,
step03,
step04,
step05,
step06,
@@ -26,6 +26,20 @@ export function hasModelConnected(s: RootState): boolean {
return false;
}
/** True while the server-funded free trial is armed (no key needed yet). */
export function hasFreeTrialActive(s: RootState): boolean {
const d = s.settings.data as any;
return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token);
}
/** True when free runs are running out (or already armed-and-spent). Surfaces the connect-model step. */
export function freeRunsLow(s: RootState): boolean {
const d = s.settings.data as any;
if (!d) return false;
const remaining = d.free_trial_remaining;
return typeof remaining === 'number' && remaining <= 2;
}
export function hasAnyToolEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
// Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on.
@@ -1,16 +1,19 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasModelConnected } from './skipPredicates';
import { hasModelConnected, hasFreeTrialActive, freeRunsLow } from './skipPredicates';
export const step01: OnboardingStep = {
id: 'connect_model',
stage: 'get_started',
index: 1,
title: 'Connect an AI model',
description: 'This is the brain behind your agents.',
// Moved to last in "Get started": the user only meets this after they've
// seen value, framed as "keep going". Stays suppressed while the free trial
// is armed and runs aren't low; un-suppresses when they're about to run out.
index: 2,
title: 'Keep going: connect your model',
description: 'Your free runs are limited. Add your own model to keep building.',
videoSrc: './onboarding-videos/v2/01.mp4',
videoDurationLabel: '0:24',
skipIf: hasModelConnected,
skipIf: (s) => hasModelConnected(s) || (hasFreeTrialActive(s) && !freeRunsLow(s)),
ops: [
{ kind: 'move_to', target: S.sidebarSettingsButton },
{ kind: 'popup', text: 'Pop into Settings.' },
@@ -4,13 +4,14 @@ import { isYoutubeEnabled } from './skipPredicates';
export const step02: OnboardingStep = {
id: 'enable_actions',
stage: 'get_started',
index: 2,
// Demoted out of the first-run path: a feature to discover after the first win.
stage: 'learn_features',
index: 3,
title: 'Enable agentic actions',
description: 'Allow agents to work across your apps.',
videoSrc: './onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
// Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on.
// Narrowed to YouTube so users with other tools still get walked.
skipIf: isYoutubeEnabled,
ops: [
{ kind: 'move_to', target: S.sidebarActions },
@@ -11,7 +11,9 @@ const FALLBACK_PROMPT =
export const step03: OnboardingStep = {
id: 'launch_agent',
stage: 'get_started',
index: 3,
// Value first: this is now step 1. With the free trial armed it runs with no
// model connected and no sign-in, so the user sees an agent work immediately.
index: 1,
title: 'Launch your first Agent',
description: 'Click the chat bubble to fire up a new Agent in a dashboard.',
videoSrc: './onboarding-videos/v2/03.mp4',
@@ -4,7 +4,7 @@ import { hasAnyBrowserSpawned } from './skipPredicates';
export const step04: OnboardingStep = {
id: 'use_browser',
stage: 'get_started',
stage: 'learn_features',
index: 4,
title: 'Use the built-in browser',
description:
@@ -101,6 +101,16 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro
ctaAction: 'upgrade',
};
}
if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) {
return {
kind: 'cap',
title: "You've used your free runs",
detail:
'Connect a model to keep going: your own API key, an AI subscription you already pay for, or OpenSwarm Pro.',
ctaLabel: 'Connect a model',
ctaAction: 'settings',
};
}
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
return {
kind: 'network',
+5 -1
View File
@@ -57,9 +57,13 @@ export interface AppSettings {
dev_mode: boolean;
allow_experimental_updates: boolean;
/** Managed subscription state; surfaces only when user has subscribed via cloud. */
connection_mode?: 'own_key' | 'openswarm-pro';
connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial';
openswarm_bearer_token?: string | null;
openswarm_proxy_url?: string | null;
/** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */
free_trial_token?: string | null;
free_trial_remaining?: number | null;
free_trial_runs_limit?: number | null;
openswarm_subscription_plan?: string | null;
openswarm_subscription_expires?: string | null;
openswarm_usage_cached?: SubscriptionUsage | null;