mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
[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:
+11
-11
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user