feat(settings): replace static license CTA with dynamic upgrade cards (#201)

Community users now see Personal Pro and Team Pro cards with feature
highlights and direct Lemon Squeezy checkout links. Personal Pro users
see only the Team Pro upgrade option. Trial and Team Pro users see no
upgrade cards since they already have full access.
This commit is contained in:
Anso
2026-03-27 23:49:11 -04:00
committed by GitHub
parent 0cc198f86d
commit d3828e885d
6 changed files with 116 additions and 59 deletions
+99 -47
View File
@@ -18,7 +18,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug } from 'lucide-react';
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, Check, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug, Zap } from 'lucide-react';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { NodeManager } from './NodeManager';
@@ -647,7 +647,7 @@ function UsersSection() {
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const { license, isPro, activate, deactivate, checkout } = useLicense();
const { license, isPro, activate, deactivate } = useLicense();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<SectionId>('account');
const [licenseKeyInput, setLicenseKeyInput] = useState('');
@@ -1184,59 +1184,111 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
)}
{/* Subscribe / Activate (not active) */}
{license?.status !== 'active' && (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-base">Subscribe to Sencho Pro</Label>
<p className="text-xs text-muted-foreground">
Purchase a license key from our website, then activate it below.
</p>
</div>
<Button
size="sm"
onClick={() => checkout()}
>
<Crown className="w-4 h-4 mr-2" />
View Pricing
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
{/* Upgrade Cards — Community: show both, Personal Pro: show Team only, Team Pro: none */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Personal Pro Card — only for Community users */}
{license?.tier !== 'pro' && (
<div className="relative border border-border rounded-xl p-4 space-y-3 bg-muted/5">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-amber-500" />
<span className="font-semibold text-sm">Personal Pro</span>
<Badge variant="secondary" className="text-[10px] font-semibold uppercase px-1.5 py-0">Popular</Badge>
</div>
<p className="text-xs text-muted-foreground">Professional tools for solo operators.</p>
<ul className="space-y-1.5">
{['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-green-500" />
{f}
</li>
))}
</ul>
<Button
size="sm"
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Personal Pro
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
{/* License key activation */}
<div className="border-t border-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
<div className="flex gap-2">
<Input
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
/>
{/* Team Pro Card */}
<div className="border border-border rounded-xl p-4 space-y-3 bg-muted/5">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="font-semibold text-sm">Team Pro</span>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(license?.variant === 'personal' ? ['Everything in Personal Pro'] : ['Everything in Community']),
'Unlimited admin accounts',
'Unlimited viewer accounts',
...(license?.variant !== 'personal' ? ['Fleet View & webhooks', 'Atomic deployment & backups'] : []),
'Team onboarding assistance',
].map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-green-500" />
{f}
</li>
))}
</ul>
<Button
variant="outline"
onClick={async () => {
if (!licenseKeyInput.trim()) return;
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
}
setIsActivating(false);
}}
disabled={isActivating || !licenseKeyInput.trim()}
size="sm"
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
className="w-full"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
{isActivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Activating...</>
: 'Activate'
}
<Zap className="w-4 h-4 mr-2" />
Get Team Pro
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
</div>
</div>
)}
{/* License key activation — show when not active */}
{license?.status !== 'active' && (
<div className="border-t border-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
<div className="flex gap-2">
<Input
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
/>
<Button
variant="outline"
onClick={async () => {
if (!licenseKeyInput.trim()) return;
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
}
setIsActivating(false);
}}
disabled={isActivating || !licenseKeyInput.trim()}
>
{isActivating
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Activating...</>
: 'Activate'
}
</Button>
</div>
</div>
)}
</div>
)}
+1 -6
View File
@@ -26,7 +26,6 @@ interface LicenseContextType {
refresh: () => Promise<void>;
activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>;
deactivate: () => Promise<{ success: boolean; error?: string }>;
checkout: () => void;
}
const LicenseContext = createContext<LicenseContextType | undefined>(undefined);
@@ -88,14 +87,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
}
}, []);
const checkout = useCallback(() => {
window.open('https://sencho.io/#pricing', '_blank');
}, []);
const isPro = license?.tier === 'pro';
return (
<LicenseContext.Provider value={{ license, isPro, loading, refresh, activate, deactivate, checkout }}>
<LicenseContext.Provider value={{ license, isPro, loading, refresh, activate, deactivate }}>
{children}
</LicenseContext.Provider>
);