[eric] OpenSwarm Pro 3-tier picker: Pro/Pro+/Ultra cards with monthly/annual toggle across Settings, Onboarding, and 429 error card, dynamic

Subscribe/Resubscribe/Upgrade/Downgrade CTA per tier, Anthropic-style layout and copy, bigger onboarding text
This commit is contained in:
ciregenz
2026-04-17 15:28:26 -07:00
parent b469648e05
commit 6a1dfae934
8 changed files with 554 additions and 153 deletions
@@ -177,13 +177,13 @@
}
},
"node_modules/@azure/identity/node_modules/@azure/msal-node": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.2.tgz",
"integrity": "sha512-DoeSJ9U5KPAIZoHsPywvfEj2MhBniQe0+FSpjLUTdWoIkI999GB5USkW6nNEHnIaLVxROHXvprWA1KzdS1VQ4A==",
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.3.tgz",
"integrity": "sha512-LqT8mRZpEils9zGR9eW+Ljqifh2aMA99UF/X0jxIKDYZeHr6onlHwhVP4xHCeLhh55BI63JCbdf1iWJbMh1mPw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@azure/msal-common": "16.4.1",
"@azure/msal-common": "16.5.0",
"jsonwebtoken": "^9.0.0",
"uuid": "^8.3.0"
},
@@ -284,22 +284,22 @@
}
},
"node_modules/@azure/msal-browser": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.3.tgz",
"integrity": "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.7.0.tgz",
"integrity": "sha512-uYbJ0YarxkVGWEq814BysJry/IPvpDNkVKmc2bMZp4G+igUQkJ5nlFirycwPGUeA9ICLQqCxqExCA1Z1E07bPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@azure/msal-common": "16.4.1"
"@azure/msal-common": "16.5.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz",
"integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==",
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.0.tgz",
"integrity": "sha512-i3eS/5pmxDbIU/mLMENs88Qg3k6XxqJytJy6PpB7L1tCBjdXHJDadCD3Hu1TyTooe7iQo7CYqbocgL/l/8u90g==",
"license": "MIT",
"optional": true,
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.22",
"version": "1.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.22",
"version": "1.0.23",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
+64 -58
View File
@@ -1,10 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, Modal, Button, CircularProgress, TextField, InputAdornment } from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import PlanPicker from '@/app/components/PlanPicker';
// Email validation: format check + typo correction for common domains.
// Real ownership verification is intentionally pushed downstream (mailing list /
@@ -122,7 +124,7 @@ const OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const settings = useAppSelector((s) => s.settings);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'profile' | 'walkthrough' | 'connect'>('profile');
const [step, setStep] = useState<'profile' | 'walkthrough' | 'connect' | 'pricing'>('profile');
const [walkthroughIdx, setWalkthroughIdx] = useState(0);
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
@@ -342,36 +344,13 @@ const OnboardingModal: React.FC = () => {
setConnecting(providerId);
trackEvent('onboarding.provider_selected', { provider: providerId });
// OpenSwarm Pro: no OAuth — just open the pricing/checkout page in the
// system browser. The post-payment openswarm://auth deep link will
// dismiss this modal automatically via useDeepLink → fetchSettings.
// OpenSwarm Pro: switch to the dedicated pricing step so the user can
// pick a tier + billing interval before heading to Stripe. The
// post-payment openswarm://auth deep link will dismiss this modal
// automatically via useDeepLink → fetchSettings.
if (providerId === 'openswarm-pro') {
trackEvent('subscription.subscribe_clicked', {
source: 'onboarding',
plan: 'pro',
billing_interval: 'monthly',
});
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
});
if (r.ok) {
const { url } = await r.json();
if (url) {
trackEvent('subscription.checkout_opened', {
source: 'onboarding',
plan: 'pro',
});
}
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
}
} catch (e) {
console.error('Failed to create checkout session:', e);
}
setConnecting(null);
setStep('pricing');
return;
}
@@ -494,7 +473,7 @@ const OnboardingModal: React.FC = () => {
return (
<Modal open={open} onClose={step === 'connect' ? handleSkip : undefined} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{
width: step === 'walkthrough' ? 600 : 480, maxWidth: '90vw',
width: step === 'walkthrough' ? 600 : step === 'pricing' ? 780 : 480, maxWidth: '90vw',
bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
p: step === 'walkthrough' ? 0 : 3.5, outline: 'none',
@@ -502,14 +481,14 @@ const OnboardingModal: React.FC = () => {
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
{step !== 'walkthrough' && (
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
<Typography sx={{ fontSize: '1.5rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
)}
{step === 'profile' ? (
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 2.5, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 2.5, textAlign: 'center' }}>
Tell us a bit about yourself
</Typography>
@@ -522,7 +501,7 @@ const OnboardingModal: React.FC = () => {
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.82rem',
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
@@ -557,7 +536,7 @@ const OnboardingModal: React.FC = () => {
}}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.82rem',
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: showError ? c.status.error : c.border.subtle },
@@ -568,12 +547,12 @@ const OnboardingModal: React.FC = () => {
}}
/>
{showError && (
<Typography sx={{ fontSize: '0.68rem', color: c.status.error, mt: 0.4, ml: 0.5 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, mt: 0.4, ml: 0.5 }}>
That doesn't look like a valid email address
</Typography>
)}
{suggestion && (
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted, mt: 0.4, ml: 0.5 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.4, ml: 0.5 }}>
Did you mean{' '}
<Box
component="span"
@@ -594,7 +573,7 @@ const OnboardingModal: React.FC = () => {
);
})()}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
What will you use OpenSwarm for?
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
@@ -612,7 +591,7 @@ const OnboardingModal: React.FC = () => {
'&:hover': { borderColor: c.border.medium },
}}
>
<Typography sx={{ fontSize: '0.72rem', color: useCases.includes(uc) ? c.accent.primary : c.text.secondary }}>
<Typography sx={{ fontSize: '0.82rem', color: useCases.includes(uc) ? c.accent.primary : c.text.secondary }}>
{uc}
</Typography>
</Box>
@@ -628,7 +607,7 @@ const OnboardingModal: React.FC = () => {
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
fontSize: '0.82rem',
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
@@ -640,7 +619,7 @@ const OnboardingModal: React.FC = () => {
/>
)}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mt: 0.5 }}>
How did you hear about OpenSwarm?
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
@@ -658,7 +637,7 @@ const OnboardingModal: React.FC = () => {
'&:hover': { borderColor: c.border.medium },
}}
>
<Typography sx={{ fontSize: '0.72rem', color: referralSource === src ? c.accent.primary : c.text.secondary }}>
<Typography sx={{ fontSize: '0.82rem', color: referralSource === src ? c.accent.primary : c.text.secondary }}>
{src}
</Typography>
</Box>
@@ -674,7 +653,7 @@ const OnboardingModal: React.FC = () => {
sx={{
mt: 0.5,
'& .MuiOutlinedInput-root': {
fontSize: '0.82rem',
fontSize: '0.92rem',
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'& fieldset': { borderColor: c.border.subtle },
@@ -692,7 +671,7 @@ const OnboardingModal: React.FC = () => {
fullWidth
disabled={!isProfileComplete}
sx={{
textTransform: 'none', fontSize: '0.82rem', fontWeight: 600,
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
bgcolor: c.accent.primary, color: '#fff',
borderRadius: `${c.radius.md}px`, py: 1,
'&:hover': { bgcolor: c.accent.hover },
@@ -757,7 +736,7 @@ const OnboardingModal: React.FC = () => {
<Box
sx={{
display: 'inline-block',
fontSize: '0.62rem',
fontSize: '0.72rem',
fontWeight: 700,
color: c.accent.primary,
bgcolor: c.accent.primary + '1a',
@@ -774,13 +753,13 @@ const OnboardingModal: React.FC = () => {
Step {walkthroughIdx + 1}
</Box>
<Typography sx={{ fontSize: '1.1rem', fontWeight: 700, color: c.text.primary, mb: 1 }}>
<Typography sx={{ fontSize: '1.25rem', fontWeight: 700, color: c.text.primary, mb: 1 }}>
{EDUCATION_STEPS[walkthroughIdx].title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2.5, minHeight: 130 }}>
{EDUCATION_STEPS[walkthroughIdx].body.map((p, i) => (
<Typography key={i} sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.6 }}>
<Typography key={i} sx={{ fontSize: '1.02rem', color: c.text.secondary, lineHeight: 1.6 }}>
{p}
</Typography>
))}
@@ -791,7 +770,7 @@ const OnboardingModal: React.FC = () => {
onClick={backWalkthrough}
disabled={walkthroughIdx === 0}
sx={{
textTransform: 'none', fontSize: '0.82rem', fontWeight: 600,
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
color: c.text.tertiary, borderRadius: `${c.radius.md}px`, px: 1.5, py: 0.75,
visibility: walkthroughIdx === 0 ? 'hidden' : 'visible',
'&:hover': { bgcolor: `${c.accent.primary}08` },
@@ -803,7 +782,7 @@ const OnboardingModal: React.FC = () => {
<Button
onClick={advanceWalkthrough}
sx={{
textTransform: 'none', fontSize: '0.82rem', fontWeight: 600,
textTransform: 'none', fontSize: '0.92rem', fontWeight: 600,
bgcolor: c.accent.primary, color: '#fff',
borderRadius: `${c.radius.md}px`, px: 2.25, py: 0.75,
'&:hover': { bgcolor: c.accent.hover },
@@ -814,14 +793,41 @@ const OnboardingModal: React.FC = () => {
</Box>
</Box>
</>
) : step === 'pricing' ? (
<>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 2.5, textAlign: 'center' }}>
Pick your OpenSwarm Pro plan
</Typography>
<PlanPicker source="onboarding" defaultPlan="pro_plus" />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Button
onClick={() => { setStep('connect'); trackEvent('onboarding.pricing_back'); }}
startIcon={<ArrowBackIcon sx={{ fontSize: 14 }} />}
sx={{
textTransform: 'none', fontSize: '0.85rem', fontWeight: 500,
color: c.text.tertiary, '&:hover': { bgcolor: `${c.accent.primary}08` },
}}
>
Back
</Button>
<Button
onClick={handleSkip}
sx={{ textTransform: 'none', fontSize: '0.82rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</Box>
</>
) : (
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
{/* Subscription options */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
@@ -839,10 +845,10 @@ const OnboardingModal: React.FC = () => {
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : !nineRouterReady ? c.text.ghost : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
<Typography sx={{ fontSize: '0.78rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : !nineRouterReady ? c.text.ghost : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : !nineRouterReady && nineRouterReady !== false ? 'Starting...' : 'Connect \u2192'}
</Typography>
</Box>
@@ -850,7 +856,7 @@ const OnboardingModal: React.FC = () => {
</Box>
{/* API key option */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
@@ -861,10 +867,10 @@ const OnboardingModal: React.FC = () => {
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
@@ -873,7 +879,7 @@ const OnboardingModal: React.FC = () => {
<Button
onClick={handleSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
sx={{ textTransform: 'none', fontSize: '0.82rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
+331
View File
@@ -0,0 +1,331 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import CheckIcon from '@mui/icons-material/Check';
import CircularProgress from '@mui/material/CircularProgress';
import { trackEvent } from '@/shared/analytics';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import {
subscribeToPlan,
OpenSwarmPlan,
BillingInterval,
CheckoutSource,
} from '@/shared/subscription/checkout';
// Pricing table. Keep in sync with the Stripe price IDs configured on
// api.openswarm.com. Annual is shown as the monthly-equivalent rate with a
// "billed annually" subtitle, mirroring Anthropic's pricing page copy.
interface PlanDef {
id: OpenSwarmPlan;
name: string;
tagline: string;
monthly: number;
annual: number; // billed monthly equivalent when paid annually
featuresHeader: string;
features: string[];
recommended?: boolean;
}
const PLANS: PlanDef[] = [
{
id: 'pro',
name: 'Pro',
tagline: 'Research, code, and organize',
monthly: 20,
annual: 17,
featuresHeader: 'Everything in Hobby and:',
features: [
'All Claude models (Sonnet, Opus, Haiku)',
'Claude Code + Cowork',
'Higher usage limits',
'Browser agents that complete tasks end-to-end',
],
},
{
id: 'pro_plus',
name: 'Pro+',
tagline: 'Higher limits, priority access',
monthly: 100,
annual: 85,
featuresHeader: 'Everything in Pro, plus:',
features: [
'Up to 5\u00d7 more usage than Pro*',
'Higher output limits per response',
'Priority access at busy times',
],
recommended: true,
},
{
id: 'ultra',
name: 'Ultra',
tagline: 'Maximum headroom',
monthly: 200,
annual: 170,
featuresHeader: 'Everything in Pro+, plus:',
features: [
'Up to 20\u00d7 more usage than Pro*',
'Highest output limits',
'First-in-line priority access',
],
},
];
interface PlanPickerProps {
source: CheckoutSource;
defaultPlan?: OpenSwarmPlan;
defaultInterval?: BillingInterval;
compact?: boolean;
// The user's current or most-recent tier, if any. Drives the CTA text on
// each card: same-tier → "Resubscribe", higher-tier → "Upgrade",
// lower-tier → "Downgrade". When undefined the user is a new customer and
// every card says "Subscribe".
currentPlan?: OpenSwarmPlan;
onSubscribed?: (plan: OpenSwarmPlan) => void;
}
// Tier ordering for upgrade/downgrade comparison.
const TIER_RANK: Record<OpenSwarmPlan, number> = {
pro: 1,
pro_plus: 2,
ultra: 3,
};
function ctaLabel(cardId: OpenSwarmPlan, cardName: string, currentPlan?: OpenSwarmPlan): string {
if (!currentPlan) return `Subscribe to ${cardName}`;
if (currentPlan === cardId) return `Resubscribe to ${cardName}`;
return TIER_RANK[cardId] > TIER_RANK[currentPlan]
? `Upgrade to ${cardName}`
: `Downgrade to ${cardName}`;
}
const PlanPicker: React.FC<PlanPickerProps> = ({
source,
defaultPlan,
defaultInterval = 'annual',
compact = false,
currentPlan,
onSubscribed,
}) => {
const c = useClaudeTokens();
const [interval, setInterval] = useState<BillingInterval>(defaultInterval);
const [pending, setPending] = useState<OpenSwarmPlan | null>(null);
React.useEffect(() => {
trackEvent('subscription.plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
}, [source, defaultPlan]);
const handleSubscribe = async (plan: OpenSwarmPlan) => {
setPending(plan);
try {
await subscribeToPlan(plan, interval, source, { wasSubscribed: !!currentPlan });
onSubscribed?.(plan);
} finally {
setPending(null);
}
};
const handleIntervalChange = (_: React.MouseEvent<HTMLElement>, next: BillingInterval | null) => {
if (!next) return;
setInterval(next);
trackEvent('subscription.billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
// still keeping the same visual hierarchy (plan name ≈ price size).
const sz = compact
? { name: '1.35rem', price: '2rem', tagline: '0.78rem', features: '0.78rem', cta: '0.82rem', micro: '0.7rem', sub: '0.68rem', hdr: '0.72rem', suffix: '0.78rem' }
: { name: '1.75rem', price: '2.4rem', tagline: '0.85rem', features: '0.85rem', cta: '0.88rem', micro: '0.72rem', sub: '0.72rem', hdr: '0.78rem', suffix: '0.85rem' };
return (
<Box sx={{ width: '100%' }}>
{/* Billing interval toggle — annual selected by default */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: compact ? 2 : 2.5 }}>
<ToggleButtonGroup
value={interval}
exclusive
onChange={handleIntervalChange}
size="small"
sx={{
'& .MuiToggleButton-root': {
textTransform: 'none',
fontSize: '0.78rem',
fontWeight: 500,
px: 2,
py: 0.5,
color: c.text.tertiary,
borderColor: c.border.subtle,
'&.Mui-selected': {
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
borderColor: `${c.accent.primary}60`,
'&:hover': { bgcolor: `${c.accent.primary}20` },
},
},
}}
>
<ToggleButton value="monthly">Monthly</ToggleButton>
<ToggleButton value="annual">Annual · save 15%</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Plan cards — grid in regular mode, stacked column in compact */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: compact ? '1fr' : 'repeat(3, 1fr)',
gap: compact ? 1.5 : 2,
alignItems: 'stretch',
}}
>
{PLANS.map((plan) => {
const price = interval === 'annual' ? plan.annual : plan.monthly;
const isRecommended = !!plan.recommended;
const isPending = pending === plan.id;
const isDefault = defaultPlan === plan.id;
return (
<Box
key={plan.id}
sx={{
position: 'relative',
p: compact ? 2 : 2.5,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${isRecommended ? c.accent.primary : c.border.subtle}`,
bgcolor: isRecommended ? `${c.accent.primary}08` : c.bg.surface,
display: 'flex',
flexDirection: 'column',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{/* Name + "your plan" indicator */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.8, mb: 0.4 }}>
<Typography sx={{ fontSize: sz.name, fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>
{plan.name}
</Typography>
{isDefault && (
<Typography sx={{ fontSize: sz.micro, color: c.text.muted, fontWeight: 500 }}>
· your plan
</Typography>
)}
</Box>
<Typography sx={{ fontSize: sz.tagline, color: c.text.muted, mb: 1.4, lineHeight: 1.35 }}>
{plan.tagline}
</Typography>
{/* Price row — big number + /mo */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.2 }}>
<Typography sx={{ fontSize: sz.price, fontWeight: 700, color: c.text.primary, lineHeight: 1 }}>
${price}
</Typography>
<Typography sx={{ fontSize: sz.suffix, color: c.text.muted, fontWeight: 500 }}>
/mo
</Typography>
</Box>
<Typography sx={{ fontSize: sz.sub, color: c.text.ghost, mb: 1.8, lineHeight: 1.35 }}>
{interval === 'annual' ? 'billed annually' : 'billed monthly'}
</Typography>
{/* CTA moved ABOVE features — Anthropic pattern. Filled accent
for the recommended tier, outlined for the others; no
separate RECOMMENDED badge needed. */}
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={pending !== null}
variant={isRecommended ? 'contained' : 'outlined'}
fullWidth
sx={{
textTransform: 'none',
fontSize: sz.cta,
fontWeight: 600,
py: compact ? 0.85 : 1.05,
borderRadius: `${c.radius.md}px`,
...(isRecommended
? { bgcolor: c.accent.primary, color: '#fff', boxShadow: 'none', '&:hover': { bgcolor: c.accent.hover, boxShadow: 'none' } }
: { borderColor: c.border.medium, color: c.text.primary, '&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}06` } }),
}}
>
{isPending ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.7 }}>
<CircularProgress size={14} sx={{ color: 'inherit' }} />
<span>Opening</span>
</Box>
) : (
ctaLabel(plan.id, plan.name, currentPlan)
)}
</Button>
{/* Microcopy row under every CTA — matches Anthropic's
reassurance-under-the-big-button pattern. */}
<Typography
sx={{
fontSize: sz.micro,
color: c.text.muted,
textAlign: 'center',
mt: 0.7,
minHeight: '1em',
}}
>
{isRecommended
? 'Most popular · cancel anytime'
: plan.id === 'ultra'
? 'No commitment · cancel anytime'
: 'Cancel anytime'}
</Typography>
{/* Divider + cumulative features — "Everything in Pro, plus:" */}
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
mt: 1.8,
pt: 1.6,
display: 'flex',
flexDirection: 'column',
gap: 0.8,
flex: 1,
}}
>
<Typography sx={{ fontSize: sz.hdr, fontWeight: 600, color: c.text.secondary, mb: 0.2 }}>
{plan.featuresHeader}
</Typography>
{plan.features.map((f) => (
<Box key={f} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.8 }}>
<CheckIcon
sx={{
fontSize: compact ? 14 : 16,
color: isRecommended ? c.accent.primary : c.text.tertiary,
mt: '2px',
flexShrink: 0,
}}
/>
<Typography sx={{ fontSize: sz.features, color: c.text.secondary, lineHeight: 1.45 }}>
{f}
</Typography>
</Box>
))}
</Box>
</Box>
);
})}
</Box>
<Typography
sx={{
fontSize: compact ? '0.65rem' : '0.7rem',
color: c.text.ghost,
textAlign: 'center',
mt: 2,
lineHeight: 1.5,
}}
>
*Usage limits apply. Prices shown don't include applicable tax.
{' '}Prices and plans are subject to change at OpenSwarm's discretion.
</Typography>
</Box>
);
};
export default PlanPicker;
@@ -22,6 +22,7 @@ import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
import ViewBubble from './ViewBubble';
import PlanPicker from '@/app/components/PlanPicker';
const streamingCursorKeyframes = `
@keyframes blink-cursor {
@@ -609,6 +610,7 @@ interface Props {
const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => {
const c = useClaudeTokens();
const [editText, setEditText] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
const { role, content } = message;
if (role === 'system') {
@@ -883,18 +885,10 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
onClick={() => {
const api = (window as any).openswarm;
if (openswarmError.ctaAction === 'upgrade') {
const url = 'https://api.openswarm.com/api/stripe/checkout';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro_plus', billing_interval: 'monthly' }),
})
.then((r) => r.json())
.then(({ url }) => {
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
})
.catch(() => {});
// Open the tier picker in a modal so the user can
// choose Pro / Pro+ / Ultra + monthly/annual instead
// of going directly to a hardcoded pro_plus checkout.
setPickerOpen(true);
} else if (openswarmError.ctaAction === 'settings') {
// Best-effort: dispatch a DOM event the Settings modal listens to
window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } }));
@@ -934,6 +928,44 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
</Box>
)}
</Box>
<Modal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: 2 }}
>
<Box sx={{
width: 460, maxWidth: '100%',
maxHeight: '90vh', overflowY: 'auto',
bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
p: 3, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 1.5 }}>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
Upgrade your plan
</Typography>
<IconButton
size="small"
onClick={() => setPickerOpen(false)}
sx={{ color: c.text.tertiary }}
aria-label="Close"
>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, mb: 2 }}>
Pick a plan to keep going. Cancel anytime from Stripe.
</Typography>
<PlanPicker
source="upgrade_cta"
defaultPlan="pro_plus"
compact
onSubscribed={() => setPickerOpen(false)}
/>
</Box>
</Modal>
</Box>
);
});
+48 -69
View File
@@ -48,6 +48,8 @@ import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import { API_BASE } from '@/shared/config';
import PlanPicker from '@/app/components/PlanPicker';
import type { OpenSwarmPlan } from '@/shared/subscription/checkout';
// NOTE: a standalone CopilotAuthButton component used to live here, but it
// referenced `/agents/copilot/{models,start-auth,poll-auth,disconnect}`
@@ -154,6 +156,14 @@ interface OpenSwarmProStatus {
} | null;
}
// Clamp an arbitrary plan name from the cloud to one of the three picker
// tiers. Falls back to pro_plus so the "recommended" default stays selected
// if the user's prior plan was hobby or an unknown value.
const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => {
if (plan === 'pro' || plan === 'pro_plus' || plan === 'ultra') return plan;
return 'pro_plus';
};
const OpenSwarmProCard: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -179,36 +189,6 @@ const OpenSwarmProCard: React.FC = () => {
return () => clearInterval(id);
}, [refresh]);
const handleSubscribe = async () => {
trackEvent('subscription.subscribe_clicked', {
source: 'settings',
plan: 'pro',
billing_interval: 'monthly',
was_subscribed: !!status?.last_plan,
});
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
});
if (r.ok) {
const { url } = await r.json();
if (url) {
trackEvent('subscription.checkout_opened', {
source: 'settings',
plan: 'pro',
});
}
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
}
} catch (e) {
console.error('Failed to create checkout session:', e);
}
};
const handleManage = async () => {
trackEvent('subscription.manage_clicked', {
plan: status?.plan ?? null,
@@ -378,17 +358,6 @@ const OpenSwarmProCard: React.FC = () => {
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
{status.status === 'canceled' && (
<Button
onClick={handleSubscribe}
disabled={busy !== null}
size="small"
variant="contained"
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
>
Resubscribe
</Button>
)}
<Button
onClick={handleManage}
disabled={busy !== null}
@@ -399,39 +368,56 @@ const OpenSwarmProCard: React.FC = () => {
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
</Button>
</Box>
{/* Canceled-in-grace: show the 3-tier picker inline so users can
pick a plan and resubscribe without clicking through a dialog.
Active (non-canceled) subscribers don't get the picker —
mid-subscription plan changes go through Stripe's Customer
Portal via "Manage in Stripe". */}
{status.status === 'canceled' && (
<>
<Box sx={{ mt: 2.5, mb: 1.5, borderTop: `1px solid ${c.border.subtle}`, pt: 2 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500, mb: 0.3 }}>
Resubscribe to keep access past {expiresLabel || 'your end date'}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted }}>
Pick any plan below you can keep your current tier or switch.
</Typography>
</Box>
<PlanPicker
source="settings"
defaultPlan={clampPickerPlan(status.plan ?? status.last_plan)}
currentPlan={clampPickerPlan(status.plan ?? status.last_plan)}
/>
</>
)}
</>
) : status.reason === 'expired' && status.last_plan ? (
// Truly expired: the bearer's subscription ended past its grace
// period. Don't show the new-user "Subscribe" prompt — make it
// clear what happened and invite them back.
// period. Show the 3-tier picker so the user can pick the same plan
// or upgrade; their prior plan is preselected visually.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro subscription has ended. Resubscribe to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
Your OpenSwarm Pro subscription has ended. Pick a plan to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Resubscribe
</Button>
<PlanPicker
source="settings"
defaultPlan={clampPickerPlan(status.last_plan)}
currentPlan={clampPickerPlan(status.last_plan)}
/>
</>
) : status.reason === 'revoked' && status.last_plan ? (
// Token revoked but subscription existed — different CTA language
// so the user knows this isn't a billing issue.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro access token was revoked. Sign back in to reconnect.
Your OpenSwarm Pro access token was revoked. Pick a plan to reconnect.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Reconnect
</Button>
<PlanPicker
source="settings"
defaultPlan={clampPickerPlan(status.last_plan)}
currentPlan={clampPickerPlan(status.last_plan)}
/>
</>
) : (
// Genuine new user — never had a subscription on this machine.
@@ -439,14 +425,7 @@ const OpenSwarmProCard: React.FC = () => {
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
One subscription, no Claude account needed. We handle everything behind the scenes.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Subscribe to OpenSwarm Pro
</Button>
<PlanPicker source="settings" defaultPlan="pro_plus" />
</>
)}
</Box>
@@ -0,0 +1,53 @@
import { trackEvent } from '@/shared/analytics';
export type OpenSwarmPlan = 'pro' | 'pro_plus' | 'ultra';
export type BillingInterval = 'monthly' | 'annual';
export type CheckoutSource = 'settings' | 'onboarding' | 'upgrade_cta';
interface SubscribeOptions {
wasSubscribed?: boolean;
}
// Kicks off a Stripe Checkout session for the given plan + interval and opens
// the returned URL in the user's default browser (or a new tab fallback).
// All subscribe CTAs across Settings, Onboarding, and the 429 error card go
// through this helper so analytics shape and error handling stay consistent.
export async function subscribeToPlan(
plan: OpenSwarmPlan,
billingInterval: BillingInterval,
source: CheckoutSource,
opts: SubscribeOptions = {},
): Promise<void> {
trackEvent('subscription.subscribe_clicked', {
source,
plan,
billing_interval: billingInterval,
was_subscribed: !!opts.wasSubscribed,
});
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan, billing_interval: billingInterval }),
});
if (!r.ok) {
console.error(`Checkout request failed: ${r.status}`);
return;
}
const { url } = await r.json();
if (!url) return;
trackEvent('subscription.checkout_opened', {
source,
plan,
billing_interval: billingInterval,
});
const api = (window as any).openswarm;
if (api?.openExternal) api.openExternal(url);
else window.open(url, '_blank');
} catch (e) {
console.error('Failed to create checkout session:', e);
}
}
File diff suppressed because one or more lines are too long