refactor(licensing): replace Pro branding with Community/Skipper/Admiral tiers (#375)

Eliminate all references to "Pro" across backend, frontend, and docs.
Internal tier value renamed from 'pro' to 'paid'; user-facing text now
uses the thematic tier names (Community, Skipper, Admiral).

- Rename LicenseTier 'pro' to 'paid' in backend and frontend types
- Rename requirePro guard to requirePaid, error code PRO_REQUIRED to PAID_REQUIRED
- Rename ProGate.tsx to PaidGate.tsx with updated copy
- Fix: trial users can now see upgrade/purchase cards in Settings
- Update all docs and openapi.yaml to use correct tier names
This commit is contained in:
Anso
2026-04-05 05:59:36 -04:00
committed by GitHub
parent a1804c8fbe
commit f516275834
33 changed files with 304 additions and 281 deletions
+2 -2
View File
@@ -17,10 +17,10 @@ function isDismissedFromStorage(): boolean {
}
export function AdmiralGate({ children, featureName = 'This feature' }: AdmiralGateProps) {
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
if (isPro && license?.variant === 'team') return <>{children}</>;
if (isPaid && license?.variant === 'team') return <>{children}</>;
if (dismissed) {
return (
@@ -14,7 +14,7 @@ import { Label } from '@/components/ui/label';
import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { PaidGate } from '@/components/PaidGate';
import cronstrue from 'cronstrue';
interface ScheduledTask {
@@ -590,8 +590,8 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
return (
<ProGate featureName="Auto-Update Policies">
<PaidGate featureName="Auto-Update Policies">
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
</ProGate>
</PaidGate>
);
}
+16 -16
View File
@@ -85,7 +85,7 @@ const formatBytes = (bytes: number) => {
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
// Stable ref so notification callbacks always read the latest nodes list
// without needing nodes in their dependency arrays (which would cause loops).
@@ -220,16 +220,16 @@ export default function EditorLayout() {
{ value: 'templates', label: 'App Store', icon: CloudDownload },
{ value: 'global-observability', label: 'Logs', icon: Activity },
);
if (isPro && isAdmin) {
if (isPaid && isAdmin) {
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
}
if (isPro && license?.variant === 'team') {
if (isPaid && license?.variant === 'team') {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
return items;
}, [isAdmin, isPro, license?.variant, can]);
}, [isAdmin, isPaid, license?.variant, can]);
// Only highlight a tab if activeView matches a nav item
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
@@ -393,7 +393,7 @@ export default function EditorLayout() {
};
const refreshLabels = async () => {
if (!isPro) return;
if (!isPaid) return;
try {
const [labelsRes, assignmentsRes] = await Promise.all([
apiFetch('/labels'),
@@ -866,8 +866,8 @@ export default function EditorLayout() {
setContainers([]);
}
// Load backup info (Pro only)
if (isPro) {
// Load backup info (Skipper+ only)
if (isPaid) {
try {
const backupRes = await apiFetch(`/stacks/${filename}/backup`);
if (backupRes.ok) setBackupInfo(await backupRes.json());
@@ -1001,7 +1001,7 @@ export default function EditorLayout() {
setContainers(Array.isArray(conts) ? conts : []);
}
// Refresh backup info
if (isPro) {
if (isPaid) {
try {
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
if (backupRes.ok) setBackupInfo(await backupRes.json());
@@ -1010,7 +1010,7 @@ export default function EditorLayout() {
} catch (error) {
console.error('Failed to deploy:', error);
const msg = (error as Error).message || 'Failed to deploy stack';
toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg);
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
} finally {
clearStackAction(stackFile);
refreshStacks(true);
@@ -1175,7 +1175,7 @@ export default function EditorLayout() {
setContainers(Array.isArray(conts) ? conts : []);
}
if (action === 'update') fetchImageUpdates();
if (action === 'deploy' && isPro) {
if (action === 'deploy' && isPaid) {
try {
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
if (backupRes.ok) setBackupInfo(await backupRes.json());
@@ -1184,7 +1184,7 @@ export default function EditorLayout() {
} catch (error) {
console.error(`Failed to ${action}:`, error);
const msg = (error as Error).message || `Failed to ${action} stack`;
toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
} finally {
clearStackAction(stackFile);
refreshStacks(true);
@@ -1426,7 +1426,7 @@ export default function EditorLayout() {
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K
</kbd>
</div>
{isPro && labels.length > 0 && (
{isPaid && labels.length > 0 && (
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
{labels.map(label => (
<ContextMenu key={label.id}>
@@ -1500,7 +1500,7 @@ export default function EditorLayout() {
: '--'}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
{isPro && stackLabelMap[file]?.length > 0 && (
{isPaid && stackLabelMap[file]?.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0 ml-1">
{stackLabelMap[file].map(l => (
<LabelDot key={l.id} color={l.color} />
@@ -1527,7 +1527,7 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</DropdownMenuItem>
{isPro && (
{isPaid && (
<LabelAssignPopover
stackName={file}
allLabels={labels}
@@ -1610,7 +1610,7 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</ContextMenuItem>
{isPro && (
{isPaid && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
@@ -1931,7 +1931,7 @@ export default function EditorLayout() {
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
{isPro && backupInfo.exists && (
{isPaid && backupInfo.exists && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
+32 -32
View File
@@ -23,7 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { toast } from '@/components/ui/toast-store';
import { LabelDot, type Label as StackLabel } from './LabelPill';
@@ -357,7 +357,7 @@ function ReconnectingOverlay() {
}
function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record<string, StackLabel[]>; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) {
const { isPro } = useLicense();
const { isPaid } = useLicense();
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
@@ -368,7 +368,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
const diskPercent = getNodeDisk(node);
const handleExpand = async () => {
if (!isPro) return;
if (!isPaid) return;
const next = !expanded;
setExpanded(next);
@@ -512,8 +512,8 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
)}
</div>
{/* Pro Expandable Stack List with Container Drill-Down */}
{isOnline && isPro && (
{/* Paid: Expandable Stack List with Container Drill-Down */}
{isOnline && isPaid && (
<div className="border-t">
<button
onClick={handleExpand}
@@ -571,7 +571,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
const { isPro } = useLicense();
const { isPaid } = useLicense();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
@@ -606,7 +606,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}, []);
const fetchLabels = useCallback(async () => {
if (!isPro) return;
if (!isPaid) return;
try {
const [labelsRes, assignmentsRes] = await Promise.all([
apiFetch('/labels', { localOnly: true }),
@@ -617,10 +617,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
} catch {
// Non-critical
}
}, [isPro]);
}, [isPaid]);
const fetchUpdateStatus = useCallback(async () => {
if (!isPro) return;
if (!isPaid) return;
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (res.ok) {
@@ -631,7 +631,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
);
}
} catch { /* non-critical */ }
}, [isPro]);
}, [isPaid]);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
@@ -704,12 +704,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
fetchUpdateStatus();
}, [fetchOverview, fetchLabels, fetchUpdateStatus]);
// Pro: auto-refresh every 30s
// Paid tier: auto-refresh every 30s
useEffect(() => {
if (!isPro) return;
if (!isPaid) return;
const interval = setInterval(() => fetchOverview(), 30000);
return () => clearInterval(interval);
}, [isPro, fetchOverview]);
}, [isPaid, fetchOverview]);
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
const hasUpdatingRef = useRef(false);
@@ -756,12 +756,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
[updateStatuses]
);
// --- Filtering & Sorting (Pro) ---
// --- Filtering & Sorting (Skipper+) ---
const processedNodes = useMemo(() => {
let filtered = [...nodes];
// Search (Pro only, but harmless if applied - free users won't see the search bar)
// Search (paid only, but harmless if applied - free users won't see the search bar)
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(n =>
@@ -770,7 +770,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
);
}
if (isPro) {
if (isPaid) {
// Status filter
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
@@ -817,7 +817,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}
return filtered;
}, [nodes, searchQuery, isPro, prefs, labelFilters, fleetStackLabelMap]);
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
return (
<div className="h-full overflow-auto p-6">
@@ -830,7 +830,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</p>
</div>
<div className="flex items-center gap-2">
{isPro && (
{isPaid && (
<Button
variant="outline"
size="sm"
@@ -865,7 +865,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem>
{isPro && (
{isPaid && (
<TabsHighlightItem value="snapshots">
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
@@ -907,8 +907,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Fleet Content */}
{!loading && nodes.length > 0 && (
<>
{/* Pro: Fleet Health Summary Cards */}
{isPro && onlineNodes.length > 0 && (
{/* Paid: Fleet Health Summary Cards */}
{isPaid && onlineNodes.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
icon={Box}
@@ -938,8 +938,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</div>
)}
{/* Pro: Search, Sort & Filter Toolbar */}
{isPro && (
{/* Paid: Search, Sort & Filter Toolbar */}
{isPaid && (
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
@@ -1050,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={isPro ? triggerNodeUpdate : undefined}
onUpdate={isPaid ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
/>
))}
@@ -1076,18 +1076,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</div>
)}
{/* Pro auto-refresh indicator */}
{isPro && (
{/* Paid tier auto-refresh indicator */}
{isPaid && (
<p className="text-xs text-muted-foreground text-center mt-6">
Auto-refreshing every 30 seconds
</p>
)}
{/* Free tier: Pro gate for advanced features */}
{!isPro && nodes.length > 0 && (
{/* Free tier: paid gate for advanced features */}
{!isPaid && nodes.length > 0 && (
<div className="mt-6">
<ProGate featureName="Fleet Management">
{/* Preview of what Pro unlocks */}
<PaidGate featureName="Fleet Management">
{/* Preview of what paid tier unlocks */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl border bg-card p-4 h-24" />
<div className="rounded-xl border bg-card p-4 h-24" />
@@ -1098,14 +1098,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
<div className="h-9 rounded-md border bg-card w-[150px]" />
</div>
</ProGate>
</PaidGate>
</div>
)}
</>
)}
</TabsContent>
{isPro && (
{isPaid && (
<TabsContent value="snapshots">
<FleetSnapshots />
</TabsContent>
@@ -3,7 +3,7 @@ import { Compass } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
interface ProGateProps {
interface PaidGateProps {
children: ReactNode;
featureName?: string;
}
@@ -16,11 +16,11 @@ function isDismissedFromStorage(): boolean {
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
}
export function ProGate({ children, featureName = 'This feature' }: ProGateProps) {
const { isPro } = useLicense();
export function PaidGate({ children, featureName = 'This feature' }: PaidGateProps) {
const { isPaid } = useLicense();
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
if (isPro) return <>{children}</>;
if (isPaid) return <>{children}</>;
if (dismissed) {
return (
@@ -31,7 +31,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Compass className="w-3 h-3" />
Upgrade to Pro to unlock
Upgrade to unlock more features
</div>
</div>
</div>
@@ -44,9 +44,9 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
<Compass className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2">{featureName} requires Sencho Pro</h3>
<h3 className="text-lg font-semibold mb-2">{featureName} requires a paid license</h3>
<p className="text-sm text-muted-foreground">
Unlock advanced features like fleet management, viewer accounts, and more with a Sencho Pro license.
Unlock features like fleet management, viewer accounts, and more with a Skipper or Admiral license.
</p>
</div>
<div className="flex gap-3">
@@ -65,7 +65,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<Compass className="w-4 h-4 mr-2" />
Get Sencho Pro
View Plans
</Button>
</div>
</div>
+5 -5
View File
@@ -20,7 +20,7 @@ import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, Alert
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import { PaidGate } from './PaidGate';
import { CapabilityGate } from './CapabilityGate';
import { formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
@@ -347,7 +347,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
export default function ResourcesView() {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const { isPro } = useLicense();
const { isPaid } = useLicense();
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
@@ -794,7 +794,7 @@ export default function ResourcesView() {
)}
>
Topology
{!isPro && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Pro</Badge>}
{!isPaid && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Skipper</Badge>}
</button>
</div>
{isAdmin && networkViewMode === 'list' && (
@@ -813,7 +813,7 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<ProGate featureName="Network Topology">
<PaidGate featureName="Network Topology">
<CapabilityGate capability="network-topology" featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
@@ -823,7 +823,7 @@ export default function ResourcesView() {
<NetworkTopologyView />
</Suspense>
</CapabilityGate>
</ProGate>
</PaidGate>
</div>
) : (
<Table>
+10 -10
View File
@@ -47,7 +47,7 @@ interface SettingsModalProps {
export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) {
const { activeNode } = useNodes();
const { isAdmin } = useAuth();
const { license, isPro } = useLicense();
const { license, isPaid } = useLicense();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
@@ -273,7 +273,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
case 'notification-routing':
return <NotificationRoutingSection />;
case 'webhooks':
return <WebhooksSection isPro={isPro} />;
return <WebhooksSection isPaid={isPaid} />;
case 'developer':
return (
<DeveloperSection
@@ -303,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
}
};
const isTeamPro = isPro && license?.variant === 'team';
const isAdmiral = isPaid && license?.variant === 'team';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -332,19 +332,19 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
{/* Users / SSO / API Tokens / Registries */}
{!isRemote && isAdmin && (
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPro} />
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isTeamPro} />
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isTeamPro} />
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isTeamPro} />
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPro} />
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPaid} />
)}
{!isRemote && isAdmin && <Separator className="my-1.5" />}
@@ -358,10 +358,10 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
/>
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
{!isRemote && isAdmin && (
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isTeamPro} />
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPro} />
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
)}
<NavButton
section="developer"
+3 -3
View File
@@ -11,15 +11,15 @@ interface TierBadgeProps {
const tierConfig = {
community: { icon: Globe, label: 'Community' },
pro: { icon: Compass, label: 'Skipper' },
paid: { icon: Compass, label: 'Skipper' },
team: { icon: ShipWheel, label: 'Admiral' },
} as const;
function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
// Only show Team badge for active team licenses, not trials
// (trials default to team variant to unlock all features)
if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team;
if (tier === 'pro') return tierConfig.pro;
if (tier === 'paid' && variant === 'team' && status === 'active') return tierConfig.team;
if (tier === 'paid') return tierConfig.paid;
return tierConfig.community;
}
@@ -34,7 +34,7 @@ function SettingsSkeleton() {
}
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
return (
<div className="space-y-6">
@@ -143,7 +143,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
</div>
</div>
{isPro && license?.variant === 'team' && (
{isPaid && license?.variant === 'team' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
@@ -21,7 +21,7 @@ import {
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { ProGate } from '../ProGate';
import { PaidGate } from '../PaidGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
@@ -123,7 +123,7 @@ export function LabelsSection() {
};
return (
<ProGate featureName="Stack Labels">
<PaidGate featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
@@ -236,6 +236,6 @@ export function LabelsSection() {
</AlertDialogContent>
</AlertDialog>
</CapabilityGate>
</ProGate>
</PaidGate>
);
}
@@ -12,8 +12,14 @@ import {
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
function getTierDisplayName(tier?: string, variant?: string | null, status?: string): string {
if (tier === 'paid' && variant === 'team' && status === 'active') return 'Sencho Admiral';
if (tier === 'paid') return 'Sencho Skipper';
return 'Sencho Community';
}
export function LicenseSection() {
const { license, activate, deactivate } = useLicense();
const { license, isPaid, activate, deactivate } = useLicense();
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [isActivating, setIsActivating] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
@@ -36,24 +42,27 @@ export function LicenseSection() {
}
};
const showSkipperCard = !isPaid || license?.status === 'trial';
const showUpgradeCards = showSkipperCard || (license?.variant === 'personal' && license?.status === 'active');
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">License</h3>
<p className="text-sm text-muted-foreground">Manage your Sencho Pro license.</p>
<p className="text-sm text-muted-foreground">Manage your Sencho license.</p>
</div>
{/* Current Tier Display */}
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{license?.tier === 'pro' ? (
{isPaid ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<Crown className="w-5 h-5 text-muted-foreground" />
)}
<span className="font-medium text-base">
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
{getTierDisplayName(license?.tier, license?.variant, license?.status)}
</span>
</div>
<TierBadge />
@@ -98,7 +107,7 @@ export function LicenseSection() {
{license?.status === 'expired' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your Pro license has expired. Renew to restore Pro features.</span>
<span>Your license has expired. Renew to restore paid features.</span>
</div>
)}
@@ -110,7 +119,7 @@ export function LicenseSection() {
)}
</div>
{/* Manage Subscription (active Pro) */}
{/* Manage Subscription (active paid license) */}
{license?.status === 'active' && (
<div className="space-y-3">
<Button
@@ -155,13 +164,13 @@ export function LicenseSection() {
</div>
)}
{/* Upgrade Cards */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
{/* Upgrade Cards: show for community, trial, or active Skipper users */}
{showUpgradeCards && (
<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'}`}>
{/* Skipper Card - only for Community users */}
{license?.tier !== 'pro' && (
<div className={`grid gap-3 ${showSkipperCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users and trial users */}
{showSkipperCard && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<Compass className="w-4 h-4 text-amber-500" />
@@ -213,7 +222,7 @@ export function LicenseSection() {
</ul>
<Button
size="sm"
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
variant={showSkipperCard ? 'outline' : 'default'}
className="w-full mt-auto"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
@@ -244,7 +253,7 @@ export function LicenseSection() {
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
toast.success('License activated successfully.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
@@ -4,7 +4,7 @@ import { TierBadge } from '@/components/TierBadge';
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
export function SupportSection() {
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
return (
<div className="space-y-6">
@@ -42,11 +42,11 @@ export function SupportSection() {
</div>
</div>
{/* Pro support channels */}
{isPro && (
{/* Paid tier support channels */}
{isPaid && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
Pro Support <TierBadge />
Priority Support <TierBadge />
</h4>
<div className="grid gap-3">
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
@@ -71,17 +71,17 @@ export function SupportSection() {
)}
{/* Upsell for Community */}
{!isPro && (
{!isPaid && (
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
<div className="flex items-start gap-3">
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Need faster support?</p>
<p className="text-xs text-muted-foreground mt-1">
Upgrade to Pro for direct email support and priority issue handling.
Upgrade to Skipper or Admiral for direct email support and priority issue handling.
</p>
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
Upgrade to Pro
View Plans
</Button>
</div>
</div>
@@ -13,7 +13,7 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from '@/components/ProGate';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
@@ -35,7 +35,7 @@ interface RoleAssignmentItem {
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@@ -230,7 +230,7 @@ export function UsersSection() {
};
return (
<ProGate featureName="User management">
<PaidGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
@@ -267,7 +267,7 @@ export function UsersSection() {
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPro && license?.variant === 'team' && (
{isPaid && license?.variant === 'team' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
@@ -306,7 +306,7 @@ export function UsersSection() {
</div>
{/* Scoped Permissions (Admiral, editing only) */}
{editingUser && isPro && license?.variant === 'team' && (
{editingUser && isPaid && license?.variant === 'team' && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
@@ -455,6 +455,6 @@ export function UsersSection() {
)}
</div>
</CapabilityGate>
</ProGate>
</PaidGate>
);
}
@@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import {
@@ -38,7 +38,7 @@ interface WebhookExecution {
executed_at: number;
}
export function WebhooksSection({ isPro }: { isPro: boolean }) {
export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -134,21 +134,21 @@ export function WebhooksSection({ isPro }: { isPro: boolean }) {
toast.success(`${label} copied to clipboard.`);
};
if (!isPro) {
if (!isPaid) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<ProGate featureName="Webhooks">
<PaidGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</ProGate>
</PaidGate>
</div>
);
}