Desktop connection SaaS: config, billing, team support (#5768)

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-02-25 14:13:07 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 86072ec91a
commit 5c39acecd8
76 changed files with 6231 additions and 119 deletions
@@ -1,7 +1,12 @@
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
import { useConfigNavSections as useProprietaryConfigNavSections, createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
import { ConnectionSettings } from '@app/components/ConnectionSettings';
import { SaasPlanSection } from '@app/components/shared/config/configSections/SaasPlanSection';
import { SaaSTeamsSection } from '@app/components/shared/config/configSections/SaaSTeamsSection';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
/**
* Hook version of desktop config nav sections with proper i18n support
@@ -13,11 +18,54 @@ export const useConfigNavSections = (
): ConfigNavSection[] => {
const { t } = useTranslation();
// Check if in SaaS mode and authenticated (for Team section visibility)
const [isSaasMode, setIsSaasMode] = useState<boolean>(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
useEffect(() => {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsAuthenticated(auth);
};
checkAccess();
// Subscribe to connection mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
});
return unsubscribe;
}, []);
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
// Identifies self-hosted admin sections by their first item's stable key.
// Using item keys avoids dependency on translated section titles (#17).
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
'people', // Workspace section
'adminGeneral', // Configuration section
'adminSecurity', // Security & Authentication section
'adminPlan', // Licensing & Analytics section
'adminLegal', // Policies & Privacy section
]);
// Build the result array explicitly instead of splice with hardcoded indices (#18).
const result: ConfigNavSection[] = [];
// Preferences is always first
if (sections.length > 0) result.push(sections[0]);
// Connection Mode always sits immediately after Preferences
result.push({
title: t('settings.connection.title', 'Connection Mode'),
items: [
{
@@ -29,7 +77,42 @@ export const useConfigNavSections = (
],
});
return sections;
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
result.push({
title: t('settings.planBilling.title', 'Plan & Billing'),
items: [
{
key: 'planBilling',
label: t('settings.planBilling.title', 'Plan & Billing'),
icon: 'credit-card',
component: <SaasPlanSection />,
},
],
});
result.push({
title: t('settings.team.title', 'Team'),
items: [
{
key: 'teams',
label: t('settings.team.title', 'Team'),
icon: 'groups-rounded',
component: <SaaSTeamsSection />,
},
],
});
}
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
for (const section of sections.slice(1)) {
const firstItemKey = section.items[0]?.key;
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
continue;
}
result.push(section);
}
return result;
};
/**
@@ -59,5 +142,18 @@ export const createConfigNavSections = (
],
});
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: 'Plan & Billing',
items: [
{
key: 'planBilling',
label: 'Plan & Billing',
icon: 'credit-card',
component: <SaasPlanSection />,
},
],
});
return sections;
};
@@ -0,0 +1,513 @@
import React, { useState, useEffect } from 'react';
import { Button, TextInput, Group, Text, Stack, Alert, Table, Badge, ActionIcon, Menu, List, ThemeIcon, Modal, CloseButton, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import apiClient from '@app/services/apiClient';
/**
* Desktop SaaS Teams Section
* Allows team management for users connected to SaaS backend
* CRITICAL: Only shown when in SaaS mode (enforced by navigation)
*/
export function SaaSTeamsSection() {
const { t } = useTranslation();
const {
currentTeam,
teamMembers,
teamInvitations,
isTeamLeader,
isPersonalTeam,
inviteUser,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
} = useSaaSTeam();
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== 'free';
const [inviteEmail, setInviteEmail] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
useEffect(() => {
// Refresh immediately on mount
refreshTeams();
// Then refresh every 10 seconds
const interval = setInterval(() => {
refreshTeams();
}, 10000);
return () => clearInterval(interval);
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'planBilling' } }));
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviting(true);
setError(null);
setSuccess(null);
try {
await inviteUser(inviteEmail);
setSuccess(t('team.inviteSent', 'Invitation sent to {{email}}', { email: inviteEmail }));
setInviteEmail('');
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.inviteError', 'Failed to send invitation'));
} finally {
setInviting(false);
}
};
const handleRemove = async (memberId: number, memberEmail: string) => {
if (!window.confirm(t('team.confirmRemove', 'Remove {{email}} from the team?', { email: memberEmail }))) return;
try {
await removeMember(memberId);
setSuccess(t('team.memberRemoved', 'Member removed successfully'));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.removeError', 'Failed to remove member'));
}
};
const handleCancelInvitation = async (invitationId: number, email: string) => {
if (!window.confirm(t('team.confirmCancelInvite', 'Cancel invitation for {{email}}?', { email }))) return;
try {
await cancelInvitation(invitationId);
setSuccess(t('team.inviteCancelled', 'Invitation for {{email}} cancelled', { email }));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.cancelInviteError', 'Failed to cancel invitation'));
}
};
const handleStartRename = () => {
if (currentTeam) {
setNewTeamName(currentTeam.name);
setIsEditingName(true);
}
};
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName('');
};
const handleRenameSubmit = async () => {
if (!currentTeam || !newTeamName.trim()) return;
setRenamingTeam(true);
setError(null);
try {
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, {
newName: newTeamName.trim(),
});
setSuccess(t('team.renameSuccess', 'Team renamed successfully'));
setIsEditingName(false);
await refreshTeams();
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.renameError', 'Failed to rename team'));
} finally {
setRenamingTeam(false);
}
};
const handleLeaveTeam = async () => {
if (!currentTeam || isPersonalTeam) return;
const confirmMessage = isTeamLeader
? t('team.confirmLeaveLeader', 'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.', { name: currentTeam.name })
: t('team.confirmLeave', 'Are you sure you want to leave "{{name}}"?', { name: currentTeam.name });
if (!window.confirm(confirmMessage)) return;
try {
await leaveTeam();
setSuccess(t('team.leaveSuccess', 'Successfully left team'));
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.leaveError', 'Failed to leave team'));
}
};
if (!currentTeam) {
return (
<Alert color="gray">
<Text>{t('team.loading', 'Loading team information...')}</Text>
</Alert>
);
}
return (
<Stack gap="lg">
{/* Header */}
<div>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
<Group gap="xs" align="center">
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t('team.namePlaceholder', 'Team name')}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit();
if (e.key === 'Escape') handleCancelRename();
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">{currentTeam.name}</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t('team.editName', 'Edit team name')}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && <Badge color='blue'>{t('team.leader', 'LEADER')}</Badge>}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">{t('team.personal', 'Personal')}</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t('team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
</Text>
)}
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
>
{t('team.leaveButton', 'Leave Team')}
</Button>
)}
</Group>
</div>
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert color="blue" icon={<LocalIcon icon="info" width={16} height={16} />}>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">{t('team.upgrade.title', 'Upgrade to Pro to unlock team features')}</Text>
<Text size="xs" c="dimmed" mt={2}>
{t('team.upgrade.description', 'Invite members, share credits, and more.')}{' '}
<Anchor size="xs" onClick={() => setFeaturesModalOpened(true)} style={{ cursor: 'pointer' }}>
{t('common.learnMore', 'Learn more')}
</Anchor>
</Text>
</div>
<Button
size="sm"
variant="light"
onClick={navigateToPlan}
>
{t('team.upgrade.button', 'Upgrade to Pro')}
</Button>
</Group>
</Alert>
)}
{/* Team Features Modal */}
<Modal
opened={featuresModalOpened}
onClose={() => setFeaturesModalOpened(false)}
size="md"
centered
padding="xl"
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">{t('team.features.badge', 'PRO FEATURE')}</Badge>
<Text size="xl" fw={700} ta="center">
{t('team.features.title', 'Team Collaboration')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('team.features.subtitle', 'Upgrade to Pro and unlock powerful team features')}
</Text>
</Stack>
{/* Features List */}
<List
spacing="md"
size="sm"
icon={
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
<LocalIcon icon="check" width={14} height={14} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={500}>{t('team.features.invite.title', 'Invite team members')}</Text>
<Text size="xs" c="dimmed">{t('team.features.invite.description', 'Add unlimited users with additional seat purchases')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.credits.title', 'Share credits across your team')}</Text>
<Text size="xs" c="dimmed">{t('team.features.credits.description', 'Pool resources for collaborative work')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.dashboard.title', 'Team management dashboard')}</Text>
<Text size="xs" c="dimmed">{t('team.features.dashboard.description', 'Control permissions, monitor usage, and manage members')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.billing.title', 'Centralized billing')}</Text>
<Text size="xs" c="dimmed">{t('team.features.billing.description', 'One invoice for all team seats and usage')}</Text>
</List.Item>
</List>
{/* CTA Button */}
<Button
size="md"
fullWidth
onClick={() => {
setFeaturesModalOpened(false);
navigateToPlan();
}}
>
{t('team.features.viewPlans', 'View Pro Plans')}
</Button>
</Stack>
</div>
</Modal>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
{success && (
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
{success}
</Alert>
)}
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">{t('team.invite.title', 'Invite Team Member')}</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t('team.invite.placeholder', '[email protected]')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) ? t('team.invite.invalidEmail', 'Invalid email format') : undefined}
/>
<Button
type="submit"
loading={inviting}
disabled={!inviteEmail.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)}
>
{t('team.invite.sendButton', 'Send Invite')}
</Button>
</Group>
</form>
</div>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">{t('team.members.title', 'Team Members')}</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.nameColumn', 'Name')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.emailColumn', 'Email')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.roleColumn', 'Role')}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t('team.members.empty', 'No team members yet.')}
</Text>
</Table.Td>
</Table.Tr>
) : (
<>
{/* Active Members */}
{teamMembers.map((member) => (
<Table.Tr key={`member-${member.id}`}>
<Table.Td>
<Text size="sm" fw={500}>
{member.username}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{member.email}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={member.role === 'LEADER' ? 'blue' : undefined}
style={member.role !== 'LEADER' ? {
backgroundColor: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
} : undefined}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== 'LEADER' && (
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Menu.Target>
<ActionIcon variant="subtle">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemove(member.id, member.email)}
>
{t('team.members.remove', 'Remove from Team')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
)}
</Table.Tr>
))}
{/* Pending Invitations */}
{teamInvitations
.filter(inv => inv.status === 'PENDING')
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split('@')[0]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{invitation.inviteeEmail}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t('team.members.pending', 'PENDING')}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
aria-label={t('team.invite.cancelLabel', 'Cancel invitation')}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Table.Td>
)}
</Table.Tr>
))}
</>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -0,0 +1,225 @@
import { useEffect, useState } from 'react';
import { Stack, Loader, Alert, Button, Center, Text, Flex } from '@mantine/core';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSPlans } from '@app/hooks/useSaaSPlans';
import { connectionModeService } from '@app/services/connectionModeService';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { ActiveSubscriptionCard } from '@app/components/shared/config/configSections/plan/ActiveSubscriptionCard';
import { SaaSAvailablePlansSection } from '@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection';
/**
* SaaS Plan & Billing section
* Shows subscription status, billing information, and usage metrics
* Only visible when connected to SaaS
*/
export function SaasPlanSection() {
const { t } = useTranslation();
const [isSaasMode, setIsSaasMode] = useState<boolean | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
// Billing context
const {
subscription,
usage,
tier,
isTrialing,
trialDaysRemaining,
loading,
error,
refreshBilling,
price,
currency,
isManagedTeamMember,
openBillingPortal,
} = useSaaSBilling();
// Team data for ActiveSubscriptionCard
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
// Plans data
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans('usd');
// Check connection mode on mount
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
setIsSaasMode(mode === 'saas');
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
setIsSaasMode(config.mode === 'saas');
});
return unsubscribe;
}, []);
// Handle "Manage Billing" button click
const handleManageBilling = async () => {
setIsOpeningPortal(true);
try {
// Context handles opening portal and auto-refresh
await openBillingPortal();
} catch (error) {
console.error('[SaasPlanSection] Failed to open billing portal:', error);
} finally {
setIsOpeningPortal(false);
}
};
// Format date for trial end
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Don't render anything if not in SaaS mode
if (isSaasMode === false) {
return (
<Center p="xl">
<Alert color="blue" variant="light" icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}>
<Text size="sm">
{t(
'settings.planBilling.notAvailable',
'Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).'
)}
</Text>
</Alert>
</Center>
);
}
// Loading state while checking mode
if (isSaasMode === null) {
return (
<Center p="xl">
<Loader size="sm" />
</Center>
);
}
// Loading state while fetching billing/team data
// Note: loading already includes teamLoading from billing context
if (loading) {
return (
<Center p="xl">
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('settings.planBilling.loading', 'Loading billing information...')}
</Text>
</Stack>
</Center>
);
}
// Error state
if (error) {
return (
<Center p="xl">
<Alert
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')}
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t('settings.planBilling.errors.retry', 'Retry')}
</Button>
</Stack>
</Alert>
</Center>
);
}
// Main content
return (
<SaaSCheckoutProvider>
<div>
{/* Header with title and Manage Billing button */}
<Flex justify="space-between" align="center" mb="md">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('settings.planBilling.currentPlan', 'Active Plan')}
</h3>
{tier !== 'free' && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
onClick={handleManageBilling}
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t('settings.planBilling.billing.manageBilling', 'Manage Billing')}
</Button>
)}
</Flex>
{/* Trial Status Alert */}
{isTrialing && trialDaysRemaining !== undefined && subscription?.currentPeriodEnd && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('settings.planBilling.trial.title', 'Free Trial Active')}
>
<Text size="sm">
{t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', {
days: trialDaysRemaining,
defaultValue: `Your trial ends in ${trialDaysRemaining} days`,
})}
</Text>
<Text size="xs" c="dimmed">
{t('settings.planBilling.trial.endDate', 'Expires: {{date}}', {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
</Text>
</Alert>
)}
{/* Plan cards */}
<Stack gap="lg">
{/* Current subscription card */}
<ActiveSubscriptionCard
tier={tier}
subscription={subscription}
usage={usage}
isTrialing={isTrialing}
price={price}
currency={currency}
currentTeam={currentTeam}
isTeamLeader={isTeamLeader}
isPersonalTeam={isPersonalTeam}
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
</Stack>
</div>
</SaaSCheckoutProvider>
);
}
@@ -0,0 +1,220 @@
import { Card, Text, Group, Badge, Stack, Tooltip, ActionIcon } from '@mantine/core';
import GroupIcon from '@mui/icons-material/Group';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface TeamData {
teamId: number;
name: string;
isPersonal: boolean;
isLeader: boolean;
seatsUsed: number;
}
interface ActiveSubscriptionCardProps {
tier: BillingStatus['tier'];
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
isTrialing: boolean;
price?: number;
currency?: string;
currentTeam?: TeamData | null;
isTeamLeader?: boolean;
isPersonalTeam?: boolean;
}
export function ActiveSubscriptionCard({
tier,
subscription,
usage,
isTrialing,
price,
currency,
currentTeam,
isTeamLeader = false,
isPersonalTeam = true,
}: ActiveSubscriptionCardProps) {
const { t } = useTranslation();
// Format timestamp to readable date
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Get tier display name
const getTierName = (): string => {
switch (tier) {
case 'free':
return t('settings.planBilling.tier.free', 'Free Plan');
case 'team':
return t('settings.planBilling.tier.team', 'Team Plan');
case 'enterprise':
return t('settings.planBilling.tier.enterprise', 'Enterprise Plan');
default:
return tier;
}
};
// Get price display
const getPriceDisplay = (): string => {
if (tier === 'free') {
return '$0/month';
}
// Use actual price from Stripe if available
if (price !== undefined && currency) {
return `${currency}${price}/month`;
}
// Fallback to default pricing
return '$10/month';
};
// Get description
const getDescription = (): string => {
if (tier === 'free') {
return t('settings.planBilling.tier.freeDescription', '50 credits per month');
}
return t(
'settings.planBilling.tier.teamDescription',
'500 credits/month included, automatic overage billing for uninterrupted service'
);
};
// Format overage cost
const formatOverageCost = (cents: number, credits: number): string => {
return t('settings.planBilling.billing.overageCost', {
amount: `$${(cents / 100).toFixed(2)}`,
credits,
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
});
};
// Pro/Team card
if (tier === 'team' || tier === 'enterprise') {
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
{/* Left side: Name, badges, description */}
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()}
</Text>
{!isPersonalTeam && (
<Badge color="violet" variant="light" leftSection={<GroupIcon sx={{ fontSize: 12 }} />}>
{t('settings.planBilling.tier.teamBadge', 'Team')}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipCredits', {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
})}
</Text>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipOverage', {
price: getFormattedOveragePrice(),
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
})}
</Text>
<Text size="sm">
{t('settings.planBilling.tier.teamTooltipFineprint', 'Only pay for what you use beyond included credits.')}
</Text>
</div>
}
multiline
withArrow
position="right"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<InfoOutlinedIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t('settings.planBilling.status.trial', 'Trial')}
</Badge>
)}
</Group>
{!isPersonalTeam && !isTeamLeader && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
</Text>
)}
{!isPersonalTeam && isTeamLeader && currentTeam && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
{getDescription()}
</Text>
{/* Show overage cost if applicable */}
{usage && usage.currentPeriodCredits > 0 && (
<Text size="sm" c="orange" fw={500}>
{formatOverageCost(usage.estimatedCost, usage.currentPeriodCredits)}
</Text>
)}
</div>
{/* Right side: Price */}
<div style={{ textAlign: 'right' }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
</Text>
) : (
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
)}
</div>
</Group>
{/* Next billing date at bottom */}
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
</Stack>
</Card>
);
}
// Free plan card
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{getTierName()}
</Text>
</Group>
<Text size="sm" c="dimmed">
{getDescription()}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
</div>
</Group>
</Card>
);
}
@@ -0,0 +1,79 @@
import React from 'react';
import { Card, Text, Button, Stack, List, ThemeIcon } from '@mantine/core';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useTranslation } from 'react-i18next';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { BILLING_CONFIG } from '@app/config/billing';
import type { TierLevel } from '@app/types/billing';
interface PlanUpgradeCardProps {
currentTier: TierLevel;
}
export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
const { t } = useTranslation();
// Don't show upgrade card if already on Team or Enterprise
if (currentTier !== 'free') {
return null;
}
const handleUpgrade = async () => {
// For MVP, direct to web SaaS for upgrades
const upgradeUrl = `${STIRLING_SAAS_URL}/account?tab=plan`;
try {
await shellOpen(upgradeUrl);
} catch (error) {
console.error('[PlanUpgradeCard] Failed to open upgrade URL:', error);
}
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')}
</Text>
{/* Team plan benefits */}
<Text size="sm" c="dimmed">
{t('settings.planBilling.upgrade.subtitle', 'Upgrade to Team for:')}
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon color="blue" size={20} radius="xl">
<CheckCircleIcon sx={{ fontSize: 12 }} />
</ThemeIcon>
}
>
<List.Item>
{t('settings.planBilling.upgrade.featureCredits', {
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`,
})}
</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureMembers', 'Unlimited team members')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureThroughput', 'Faster processing throughput')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureApi', 'API access for automation')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureSupport', 'Priority support')}</List.Item>
</List>
{/* Upgrade button */}
<Button variant="filled" fullWidth onClick={handleUpgrade}>
{t('settings.planBilling.upgrade.cta', 'Upgrade to Team')}
</Button>
<Text size="xs" c="dimmed" ta="center">
{t('settings.planBilling.upgrade.opensInBrowser', 'Opens in browser to complete upgrade')}
</Text>
</Stack>
</Card>
);
}
@@ -0,0 +1,76 @@
import React from 'react';
import { Text, SimpleGrid, Loader, Alert, Center } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { SaasPlanCard } from '@app/components/shared/config/configSections/plan/SaasPlanCard';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import type { TierLevel } from '@app/types/billing';
interface SaaSAvailablePlansSectionProps {
plans: PlanTier[];
currentTier?: TierLevel;
loading?: boolean;
error?: string | null;
}
export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps> = ({
plans,
currentTier,
loading,
error
}) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
const handleUpgradeClick = (plan: PlanTier) => {
if (plan.isContactOnly) {
// Handled by mailto link in the card
return;
}
if (plan.id === currentTier) {
// Already on this plan
return;
}
console.log('[SaaSAvailablePlansSection] Upgrade clicked for plan:', plan.id);
openCheckout(plan.id);
};
if (loading) {
return (
<Center py="xl">
<Loader size="md" />
</Center>
);
}
if (error) {
return (
<Alert color="orange" variant="light" mt="md">
<Text size="sm">
{t('plan.availablePlans.loadError', 'Unable to load plan pricing. Using default values.')}
</Text>
</Alert>
);
}
return (
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.availablePlans.title', 'Available Plans')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map(plan => (
<SaasPlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentTier}
currentTier={currentTier}
onUpgradeClick={handleUpgradeClick}
/>
))}
</SimpleGrid>
</div>
);
};
@@ -0,0 +1,195 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { FeatureListItem } from '@app/components/shared/modals/FeatureListItem';
import type { TierLevel } from '@app/types/billing';
interface SaasPlanCardProps {
plan: PlanTier;
isCurrentPlan?: boolean;
currentTier?: TierLevel;
onUpgradeClick?: (plan: PlanTier) => void;
}
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
plan,
isCurrentPlan,
currentTier,
onUpgradeClick
}) => {
const { t } = useTranslation();
// Free plan is included if user has Team or Enterprise tier
const isIncluded = plan.id === 'free' && (currentTier === 'team' || currentTier === 'enterprise');
// Determine card styling based on plan type
const getCardStyle = () => {
const baseStyle: React.CSSProperties = {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
borderWidth: 1,
position: 'relative',
overflow: 'visible',
};
if (plan.id === 'free' && isCurrentPlan) {
return {
...baseStyle,
borderColor: 'var(--border-default)',
opacity: 0.85,
};
}
if (plan.popular) {
return {
...baseStyle,
borderColor: 'rgb(59, 130, 246)',
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
};
}
return baseStyle;
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
}
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.1)';
}
};
const handleClick = () => {
if (plan.popular && !isCurrentPlan && onUpgradeClick) {
onUpgradeClick(plan);
}
};
return (
<Card
key={plan.id}
padding="md"
radius="md"
withBorder
style={getCardStyle()}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{plan.popular && (
<Badge
size="sm"
style={{
position: 'absolute',
top: -10,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgb(59, 130, 246)',
color: 'white',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
paddingLeft: '12px',
paddingRight: '12px',
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">{plan.name}</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" fw={400} c="dimmed">
{plan.isContactOnly
? t('plan.enterprise.siteLicense', 'Site License')
: plan.id === 'free'
? `50 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
: plan.overagePrice
? `500 ${t('credits.modal.monthlyCredits', 'monthly credits')} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t('credits.modal.overage', 'overage')}`
: `500 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
}
</Text>
</div>
<Stack gap="xs">
<Text size="xs" fw={500} mb="xs">
{plan.id === 'free'
? t('credits.modal.forRegularWork', 'For regular PDF work:')
: plan.id === 'enterprise'
? t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')
: t('credits.modal.everythingInFree', 'Everything in Free, plus:')
}
</Text>
{plan.highlights.map((highlight: string, index: number) => (
<FeatureListItem
key={index}
included
color={plan.id === 'free' ? 'var(--mantine-color-gray-6)' : 'var(--color-primary-600)'}
size="xs"
>
{highlight}
</FeatureListItem>
))}
</Stack>
<div className="flex-grow" />
<Button
variant={isCurrentPlan || isIncluded ? "subtle" : plan.isContactOnly ? "outline" : "filled"}
color={plan.isContactOnly ? undefined : "blue"}
disabled={isCurrentPlan || isIncluded}
fullWidth
size="sm"
radius="lg"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick?.(plan);
}}
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: 'transparent',
border: 'none',
cursor: 'default',
}),
...(plan.isContactOnly && {
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
}),
}}
component={plan.isContactOnly ? 'a' : undefined}
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
: isIncluded
? t('plan.included', 'Included')
: plan.isContactOnly
? t('plan.contact', 'Contact Sales')
: t('plan.upgrade', 'Upgrade')
}
</Button>
</Stack>
</Card>
);
};
@@ -0,0 +1,123 @@
import React from 'react';
import { Card, Text, Stack, Group, Progress, Alert } from '@mantine/core';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
interface UsageDisplayProps {
tier: BillingStatus['tier'];
usage: BillingStatus['meterUsage'];
}
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
const { t } = useTranslation();
// Credits per month based on tier
const getMonthlyCredits = (): number => {
switch (tier) {
case 'free':
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
case 'team':
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
case 'enterprise':
return 1000; // Placeholder — enterprise credits are custom
default:
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
}
};
const monthlyCredits = getMonthlyCredits();
// Format currency
const formatCurrency = (cents: number): string => {
return `$${(cents / 100).toFixed(2)}`;
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.credits.title', 'Credit Usage')}
</Text>
{/* Monthly credits info */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.included', {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
</Text>
</Group>
{/* Overage credits (if metered billing enabled) */}
{usage && usage.currentPeriodCredits > 0 && (
<>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.overage', {
count: usage.currentPeriodCredits,
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
})}
</Text>
<Text size="sm" fw={500} c="orange">
{t('settings.planBilling.credits.estimatedCost', {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
</Text>
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
</Stack>
<Alert color="blue" variant="light" icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}>
<Text size="xs">
{t('settings.planBilling.credits.overageInfo', {
price: getFormattedOveragePrice(),
defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`,
})}
</Text>
</Alert>
</>
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && (
<Alert color="green" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.noOverage', {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
</Text>
</Alert>
)}
{/* Free tier message */}
{tier === 'free' && (
<Alert color="blue" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.freeTierInfo', {
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`,
})}
</Text>
</Alert>
)}
</Stack>
</Card>
);
}
@@ -3,6 +3,7 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/
export const VALID_NAV_KEYS = [
...CORE_NAV_KEYS,
'connectionMode',
'planBilling',
] as const;
export type NavKey = typeof VALID_NAV_KEYS[number];