mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
James Brunton
James Brunton
parent
86072ec91a
commit
5c39acecd8
@@ -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>
|
||||
);
|
||||
}
|
||||
+220
@@ -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>
|
||||
);
|
||||
}
|
||||
+76
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user