Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,14 +1,17 @@
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';
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";
export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
export type { ConfigNavSection, ConfigNavItem } from "@core/components/shared/config/configNavSections";
/**
* Hook version of desktop config nav sections with proper i18n support
@@ -16,7 +19,7 @@ export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/co
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -25,30 +28,30 @@ export const useConfigNavSections = (
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
return connectionModeService.subscribeToModeChanges((config) => setConnectionMode(config.mode));
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
setIsAuthenticated(status === "authenticated");
});
return unsubscribe;
}, []);
const isSaasMode = connectionMode === 'saas';
const isLocalMode = connectionMode === 'local';
const isSaasMode = connectionMode === "saas";
const isLocalMode = connectionMode === "local";
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
const connectionModeSection: ConfigNavSection = {
title: t('settings.connection.title', 'Connection Mode'),
title: t("settings.connection.title", "Connection Mode"),
items: [
{
key: 'connectionMode',
label: t('settings.connection.title', 'Connection Mode'),
icon: 'desktop-cloud-rounded',
key: "connectionMode",
label: t("settings.connection.title", "Connection Mode"),
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
@@ -66,11 +69,11 @@ export const useConfigNavSections = (
// 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
"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).
@@ -85,23 +88,23 @@ export const useConfigNavSections = (
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
result.push({
title: t('settings.planBilling.title', 'Plan & Billing'),
title: t("settings.planBilling.title", "Plan & Billing"),
items: [
{
key: 'planBilling',
label: t('settings.planBilling.title', 'Plan & Billing'),
icon: 'credit-card',
key: "planBilling",
label: t("settings.planBilling.title", "Plan & Billing"),
icon: "credit-card",
component: <SaasPlanSection />,
},
],
});
result.push({
title: t('settings.team.title', 'Team'),
title: t("settings.team.title", "Team"),
items: [
{
key: 'teams',
label: t('settings.team.title', 'Team'),
icon: 'groups-rounded',
key: "teams",
label: t("settings.team.title", "Team"),
icon: "groups-rounded",
component: <SaaSTeamsSection />,
},
],
@@ -115,9 +118,7 @@ export const useConfigNavSections = (
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
continue;
}
const filteredItems = isAuthenticated
? section.items
: section.items.filter(item => item.key !== 'account');
const filteredItems = isAuthenticated ? section.items : section.items.filter((item) => item.key !== "account");
if (filteredItems.length === 0) continue;
result.push({ ...section, items: filteredItems });
}
@@ -132,21 +133,21 @@ export const useConfigNavSections = (
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn('createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.');
console.warn("createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.");
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
title: 'Connection',
title: "Connection",
items: [
{
key: 'connectionMode',
label: 'Connection Mode',
icon: 'desktop-cloud-rounded',
key: "connectionMode",
label: "Connection Mode",
icon: "desktop-cloud-rounded",
component: <ConnectionSettings />,
},
],
@@ -154,12 +155,12 @@ export const createConfigNavSections = (
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: 'Plan & Billing',
title: "Plan & Billing",
items: [
{
key: 'planBilling',
label: 'Plan & Billing',
icon: 'credit-card',
key: "planBilling",
label: "Plan & Billing",
icon: "credit-card",
component: <SaasPlanSection />,
},
],
@@ -1,7 +1,7 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
import React from "react";
import { Paper, Text, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
@@ -12,18 +12,18 @@ export const DefaultAppSettings: React.FC = () => {
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
{t("settings.general.defaultPdfEditor", "Default PDF editor")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
? t("settings.general.defaultPdfEditorActive", "Stirling PDF is your default PDF editor")
: isDefault === false
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
? t("settings.general.defaultPdfEditorInactive", "Another application is set as default")
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
variant={isDefault ? "light" : "filled"}
color="blue"
size="sm"
onClick={handleSetDefault}
@@ -31,8 +31,8 @@ export const DefaultAppSettings: React.FC = () => {
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
? t("settings.general.defaultPdfEditorSet", "Already Default")
: t("settings.general.setAsDefault", "Set as Default")}
</Button>
</Group>
</Paper>
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack } from '@mantine/core';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
import React from "react";
import { Stack } from "@mantine/core";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
@@ -1,11 +1,27 @@
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';
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
@@ -29,9 +45,9 @@ export function SaaSTeamsSection() {
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== 'free';
const isPro = tier !== "free";
const [inviteEmail, setInviteEmail] = useState('');
const [inviteEmail, setInviteEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -39,7 +55,7 @@ export function SaaSTeamsSection() {
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [newTeamName, setNewTeamName] = useState("");
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
@@ -56,7 +72,7 @@ export function SaaSTeamsSection() {
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'planBilling' } }));
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }));
};
const handleInvite = async (e: React.FormEvent) => {
@@ -69,37 +85,37 @@ export function SaaSTeamsSection() {
try {
await inviteUser(inviteEmail);
setSuccess(t('team.inviteSent', 'Invitation sent to {{email}}', { email: inviteEmail }));
setInviteEmail('');
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'));
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;
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'));
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'));
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;
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 }));
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'));
setError(error.response?.data?.error || t("team.cancelInviteError", "Failed to cancel invitation"));
}
};
@@ -112,7 +128,7 @@ export function SaaSTeamsSection() {
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName('');
setNewTeamName("");
};
const handleRenameSubmit = async () => {
@@ -126,12 +142,12 @@ export function SaaSTeamsSection() {
newName: newTeamName.trim(),
});
setSuccess(t('team.renameSuccess', 'Team renamed successfully'));
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'));
setError(error.response?.data?.error || error.message || t("team.renameError", "Failed to rename team"));
} finally {
setRenamingTeam(false);
}
@@ -141,24 +157,28 @@ export function SaaSTeamsSection() {
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 });
? 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'));
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'));
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>
<Text>{t("team.loading", "Loading team information...")}</Text>
</Alert>
);
}
@@ -174,12 +194,12 @@ export function SaaSTeamsSection() {
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t('team.namePlaceholder', 'Team name')}
placeholder={t("team.namePlaceholder", "Team name")}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit();
if (e.key === 'Escape') handleCancelRename();
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") handleCancelRename();
}}
/>
<ActionIcon
@@ -191,37 +211,36 @@ export function SaaSTeamsSection() {
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<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>
<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')}
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>}
{isTeamLeader && <Badge color="blue">{t("team.leader", "LEADER")}</Badge>}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">{t('team.personal', 'Personal')}</Badge>
<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 })}
{t("team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
</Text>
)}
</div>
@@ -233,7 +252,7 @@ export function SaaSTeamsSection() {
onClick={handleLeaveTeam}
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
>
{t('team.leaveButton', 'Leave Team')}
{t("team.leaveButton", "Leave Team")}
</Button>
)}
</Group>
@@ -244,20 +263,18 @@ export function SaaSTeamsSection() {
<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 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')}
{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 size="sm" variant="light" onClick={navigateToPlan}>
{t("team.upgrade.button", "Upgrade to Pro")}
</Button>
</Group>
</Alert>
@@ -273,26 +290,28 @@ export function SaaSTeamsSection() {
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
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>
<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')}
{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')}
{t("team.features.subtitle", "Upgrade to Pro and unlock powerful team features")}
</Text>
</Stack>
@@ -307,20 +326,28 @@ export function SaaSTeamsSection() {
}
>
<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>
<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>
<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>
<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>
<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>
@@ -333,7 +360,7 @@ export function SaaSTeamsSection() {
navigateToPlan();
}}
>
{t('team.features.viewPlans', 'View Pro Plans')}
{t("team.features.viewPlans", "View Pro Plans")}
</Button>
</Stack>
</div>
@@ -355,24 +382,30 @@ export function SaaSTeamsSection() {
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">{t('team.invite.title', 'Invite Team Member')}</Text>
<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]')}
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}
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')}
{t("team.invite.sendButton", "Send Invite")}
</Button>
</Group>
</form>
@@ -381,30 +414,32 @@ export function SaaSTeamsSection() {
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">{t('team.members.title', 'Team Members')}</Text>
<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}
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.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 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 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>
)}
{isTeamLeader && !isPersonalTeam && <Table.Th style={{ width: 50 }}></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -412,7 +447,7 @@ export function SaaSTeamsSection() {
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t('team.members.empty', 'No team members yet.')}
{t("team.members.empty", "No team members yet.")}
</Text>
</Table.Td>
</Table.Tr>
@@ -434,18 +469,22 @@ export function SaaSTeamsSection() {
<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}
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' && (
{member.role !== "LEADER" && (
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Menu.Target>
<ActionIcon variant="subtle">
@@ -458,7 +497,7 @@ export function SaaSTeamsSection() {
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemove(member.id, member.email)}
>
{t('team.members.remove', 'Remove from Team')}
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -470,12 +509,12 @@ export function SaaSTeamsSection() {
{/* Pending Invitations */}
{teamInvitations
.filter(inv => inv.status === 'PENDING')
.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]}
{invitation.inviteeEmail.split("@")[0]}
</Text>
</Table.Td>
<Table.Td>
@@ -485,7 +524,7 @@ export function SaaSTeamsSection() {
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t('team.members.pending', 'PENDING')}
{t("team.members.pending", "PENDING")}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
@@ -494,7 +533,7 @@ export function SaaSTeamsSection() {
variant="subtle"
color="red"
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
aria-label={t('team.invite.cancelLabel', 'Cancel invitation')}
aria-label={t("team.invite.cancelLabel", "Cancel invitation")}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
@@ -507,7 +546,6 @@ export function SaaSTeamsSection() {
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -1,16 +1,16 @@
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';
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
@@ -48,14 +48,14 @@ export function SaasPlanSection() {
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
setIsSaasMode(mode === 'saas');
setIsSaasMode(mode === "saas");
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
setIsSaasMode(config.mode === 'saas');
setIsSaasMode(config.mode === "saas");
});
return unsubscribe;
@@ -69,7 +69,7 @@ export function SaasPlanSection() {
// Context handles opening portal and auto-refresh
await openBillingPortal();
} catch (error) {
console.error('[SaasPlanSection] Failed to open billing portal:', error);
console.error("[SaasPlanSection] Failed to open billing portal:", error);
} finally {
setIsOpeningPortal(false);
}
@@ -78,9 +78,9 @@ export function SaasPlanSection() {
// Format date for trial end
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
year: "numeric",
month: "long",
day: "numeric",
});
};
@@ -91,8 +91,8 @@ export function SaasPlanSection() {
<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).'
"settings.planBilling.notAvailable",
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
)}
</Text>
</Alert>
@@ -117,7 +117,7 @@ export function SaasPlanSection() {
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('settings.planBilling.loading', 'Loading billing information...')}
{t("settings.planBilling.loading", "Loading billing information...")}
</Text>
</Stack>
</Center>
@@ -132,17 +132,12 @@ export function SaasPlanSection() {
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')}
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 variant="light" leftSection={<RefreshIcon sx={{ fontSize: 16 }} />} onClick={refreshBilling} size="xs">
{t("settings.planBilling.errors.retry", "Retry")}
</Button>
</Stack>
</Alert>
@@ -156,10 +151,10 @@ export function SaasPlanSection() {
<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 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("settings.planBilling.currentPlan", "Active Plan")}
</h3>
{tier !== 'free' && !isManagedTeamMember && (
{tier !== "free" && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
@@ -167,7 +162,7 @@ export function SaasPlanSection() {
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t('settings.planBilling.billing.manageBilling', 'Manage Billing')}
{t("settings.planBilling.billing.manageBilling", "Manage Billing")}
</Button>
)}
</Flex>
@@ -179,16 +174,16 @@ export function SaasPlanSection() {
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('settings.planBilling.trial.title', 'Free Trial Active')}
title={t("settings.planBilling.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', {
{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}}', {
{t("settings.planBilling.trial.endDate", "Expires: {{date}}", {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
@@ -212,12 +207,7 @@ export function SaasPlanSection() {
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
<SaaSAvailablePlansSection plans={plans} currentTier={tier} loading={plansLoading} error={plansError} />
</Stack>
</div>
</SaaSCheckoutProvider>
@@ -1,10 +1,10 @@
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';
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;
@@ -15,9 +15,9 @@ interface TeamData {
}
interface ActiveSubscriptionCardProps {
tier: BillingStatus['tier'];
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
tier: BillingStatus["tier"];
subscription: BillingStatus["subscription"];
usage: BillingStatus["meterUsage"];
isTrialing: boolean;
price?: number;
currency?: string;
@@ -42,21 +42,21 @@ export function ActiveSubscriptionCard({
// Format timestamp to readable date
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
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');
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;
}
@@ -64,31 +64,31 @@ export function ActiveSubscriptionCard({
// Get price display
const getPriceDisplay = (): string => {
if (tier === 'free') {
return '$0/month';
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';
return "$10/month";
};
// Get description
const getDescription = (): string => {
if (tier === 'free') {
return t('settings.planBilling.tier.freeDescription', '50 credits per month');
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'
"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', {
return t("settings.planBilling.billing.overageCost", {
amount: `$${(cents / 100).toFixed(2)}`,
credits,
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
@@ -96,7 +96,7 @@ export function ActiveSubscriptionCard({
};
// Pro/Team card
if (tier === 'team' || tier === 'enterprise') {
if (tier === "team" || tier === "enterprise") {
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
@@ -105,30 +105,33 @@ export function ActiveSubscriptionCard({
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()}
{!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')}
{t("settings.planBilling.tier.teamBadge", "Team")}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipCredits', {
{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', {
{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.')}
{t(
"settings.planBilling.tier.teamTooltipFineprint",
"Only pay for what you use beyond included credits.",
)}
</Text>
</div>
}
@@ -143,18 +146,18 @@ export function ActiveSubscriptionCard({
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t('settings.planBilling.status.trial', 'Trial')}
{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')}
{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 })}
{t("settings.planBilling.team.memberCount", "{{count}} team members", { count: currentTeam.seatsUsed })}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
@@ -169,10 +172,10 @@ export function ActiveSubscriptionCard({
</div>
{/* Right side: Price */}
<div style={{ textAlign: 'right' }}>
<div style={{ textAlign: "right" }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
{t("settings.planBilling.team.managedByTeam", "Managed by team")}
</Text>
) : (
<Text size="xl" fw={700}>
@@ -186,7 +189,8 @@ export function ActiveSubscriptionCard({
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)}
{t("settings.planBilling.billing.nextBillingDate", "Next billing date:")}{" "}
{formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
@@ -209,7 +213,7 @@ export function ActiveSubscriptionCard({
{getDescription()}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ textAlign: "right" }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
@@ -1,11 +1,11 @@
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';
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;
@@ -15,7 +15,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
const { t } = useTranslation();
// Don't show upgrade card if already on Team or Enterprise
if (currentTier !== 'free') {
if (currentTier !== "free") {
return null;
}
@@ -26,7 +26,7 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
try {
await shellOpen(upgradeUrl);
} catch (error) {
console.error('[PlanUpgradeCard] Failed to open upgrade URL:', error);
console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error);
}
};
@@ -35,12 +35,12 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')}
{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:')}
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
</Text>
<List
@@ -53,25 +53,25 @@ export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
}
>
<List.Item>
{t('settings.planBilling.upgrade.featureCredits', {
{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.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')}
{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')}
{t("settings.planBilling.upgrade.opensInBrowser", "Opens in browser to complete upgrade")}
</Text>
</Stack>
</Card>
@@ -1,10 +1,10 @@
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';
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[];
@@ -17,7 +17,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
plans,
currentTier,
loading,
error
error,
}) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
@@ -33,7 +33,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
return;
}
console.log('[SaaSAvailablePlansSection] Upgrade clicked for plan:', plan.id);
console.log("[SaaSAvailablePlansSection] Upgrade clicked for plan:", plan.id);
openCheckout(plan.id);
};
@@ -48,9 +48,7 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
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>
<Text size="sm">{t("plan.availablePlans.loadError", "Unable to load plan pricing. Using default values.")}</Text>
</Alert>
);
}
@@ -58,10 +56,10 @@ export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps>
return (
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.availablePlans.title', 'Available Plans')}
{t("plan.availablePlans.title", "Available Plans")}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map(plan => (
{plans.map((plan) => (
<SaasPlanCard
key={plan.id}
plan={plan}
@@ -1,9 +1,9 @@
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';
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;
@@ -12,30 +12,25 @@ interface SaasPlanCardProps {
onUpgradeClick?: (plan: PlanTier) => void;
}
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
plan,
isCurrentPlan,
currentTier,
onUpgradeClick
}) => {
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');
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)',
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
borderWidth: 1,
position: 'relative',
overflow: 'visible',
position: "relative",
overflow: "visible",
};
if (plan.id === 'free' && isCurrentPlan) {
if (plan.id === "free" && isCurrentPlan) {
return {
...baseStyle,
borderColor: 'var(--border-default)',
borderColor: "var(--border-default)",
opacity: 0.85,
};
}
@@ -43,11 +38,11 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
if (plan.popular) {
return {
...baseStyle,
borderColor: 'rgb(59, 130, 246)',
borderColor: "rgb(59, 130, 246)",
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
};
}
@@ -56,15 +51,15 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
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)';
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)';
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
}
};
@@ -89,29 +84,31 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
<Badge
size="sm"
style={{
position: 'absolute',
position: "absolute",
top: -10,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgb(59, 130, 246)',
color: 'white',
left: "50%",
transform: "translateX(-50%)",
background: "rgb(59, 130, 246)",
color: "white",
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
paddingLeft: '12px',
paddingRight: '12px',
textTransform: "uppercase",
letterSpacing: "0.5px",
paddingLeft: "12px",
paddingRight: "12px",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">{plan.name}</Text>
<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}`}
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
@@ -121,30 +118,28 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
</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')}`
? 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')}`
}
? `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:')
}
{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)'}
color={plan.id === "free" ? "var(--mantine-color-gray-6)" : "var(--color-primary-600)"}
size="xs"
>
{highlight}
@@ -168,26 +163,25 @@ export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: 'transparent',
border: 'none',
cursor: 'default',
background: "transparent",
border: "none",
cursor: "default",
}),
...(plan.isContactOnly && {
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
borderColor: "var(--text-primary)",
color: "var(--text-primary)",
}),
}}
component={plan.isContactOnly ? 'a' : undefined}
component={plan.isContactOnly ? "a" : undefined}
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
? t("plan.current", "Current Plan")
: isIncluded
? t('plan.included', 'Included')
: plan.isContactOnly
? t('plan.contact', 'Contact Sales')
: t('plan.upgrade', 'Upgrade')
}
? t("plan.included", "Included")
: plan.isContactOnly
? t("plan.contact", "Contact Sales")
: t("plan.upgrade", "Upgrade")}
</Button>
</Stack>
</Card>
@@ -1,13 +1,13 @@
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';
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'];
tier: BillingStatus["tier"];
usage: BillingStatus["meterUsage"];
}
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
@@ -16,11 +16,11 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
// Credits per month based on tier
const getMonthlyCredits = (): number => {
switch (tier) {
case 'free':
case "free":
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
case 'team':
case "team":
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
case 'enterprise':
case "enterprise":
return 1000; // Placeholder — enterprise credits are custom
default:
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
@@ -39,13 +39,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.credits.title', 'Credit Usage')}
{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', {
{t("settings.planBilling.credits.included", {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
@@ -58,13 +58,13 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.overage', {
{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', {
{t("settings.planBilling.credits.estimatedCost", {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
@@ -72,19 +72,12 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
<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', {
{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.`,
})}
@@ -94,10 +87,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && (
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
<Alert color="green" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.noOverage', {
{t("settings.planBilling.credits.noOverage", {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
@@ -106,10 +99,10 @@ export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
)}
{/* Free tier message */}
{tier === 'free' && (
{tier === "free" && (
<Alert color="blue" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.freeTierInfo', {
{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.`,
@@ -1,9 +1,5 @@
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
export const VALID_NAV_KEYS = [
...CORE_NAV_KEYS,
'connectionMode',
'planBilling',
] as const;
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "connectionMode", "planBilling"] as const;
export type NavKey = typeof VALID_NAV_KEYS[number];
export type NavKey = (typeof VALID_NAV_KEYS)[number];