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

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-02-25 14:13:07 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 86072ec91a
commit 5c39acecd8
76 changed files with 6231 additions and 119 deletions
+1
View File
@@ -329,6 +329,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
@@ -82,6 +82,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
registry.addMapping("/**")
.allowedOriginPatterns(
"http://localhost:*",
"https://localhost:*",
"tauri://*", // Add this for Tauri apps
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
@@ -2242,9 +2242,11 @@ copy = "Copy"
done = "Done"
error = "Error"
expand = "Expand"
learnMore = "Learn more"
lines = "lines"
loading = "Loading..."
next = "Next"
operation = "this operation"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
@@ -2908,6 +2910,7 @@ copyStack = "Copy Stack Trace"
discordSubmit = "Discord - Submit Support post"
dismissAllErrors = "Dismiss All Errors"
encryptedPdfMustRemovePassword = "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A."
generic = "An error occurred"
github = "Submit a ticket on GitHub"
githubSubmit = "GitHub - Submit a ticket"
incorrectPasswordProvided = "The PDF password is incorrect or not provided."
@@ -4426,6 +4429,10 @@ upgradeCompleteMessage = "Your subscription has been upgraded successfully. Your
upgradeSuccess = "Payment successful! Your subscription has been upgraded. The license has been updated on your server. You will receive a confirmation email shortly."
upgradeTitle = "Upgrade to {{planName}}"
yearly = "Yearly"
checkoutOpened = "Checkout Opened in Browser"
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
refreshBilling = "I've Completed Payment - Refresh Billing"
closeLater = "I'll Do This Later"
[payment.emailStage]
continue = "Continue"
@@ -4812,6 +4819,7 @@ customPricing = "Custom"
featureComparison = "Feature Comparison"
from = "From"
hideComparison = "Hide Feature Comparison"
included = "Included"
includedInCurrent = "Included in Your Plan"
licensedSeats = "Licensed: {{count}} seats"
manage = "Manage"
@@ -4829,9 +4837,11 @@ title = "Active Plan"
[plan.availablePlans]
subtitle = "Choose the plan that fits your needs"
loadError = "Unable to load plan pricing. Using default values."
title = "Available Plans"
[plan.enterprise]
siteLicense = "Site License"
highlight1 = "Custom pricing"
highlight2 = "Dedicated support"
highlight3 = "Latest features"
@@ -4905,6 +4915,82 @@ keyDescription = "Paste the license key from your email"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.team]
name = "Team"
siteLicense = "Site License"
[credits]
enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later"
upgrade = "Upgrade"
[credits.modal]
advancedMonitoring = "Advanced monitoring"
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
apiSandbox = "API sandbox"
contactSales = "Contact Sales"
creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
customSmartFolders = "Custom Smart Folders"
dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
fasterThroughput = "10x faster throughput"
forRegularWork = "For regular PDF work:"
freeTier = "Free Tier"
fullyPrivateFiles = "Fully private files"
largeFileProcessing = "Large file processing"
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
managedMemberTitle = "Unlimited Credits"
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
meteringIncluded = "{{credits}} credits/month included with Team"
meteringNoCommitment = "No commitment, cancel anytime"
meteringNeverRunOut = "Never run out of credits"
meteringPayAsYouGo = "Only pay for what you use"
meteringPrice = "Additional credits at {{price}}/credit"
meteringTitle = "Pay-What-You-Use Overage Billing"
monthlyCredits = "monthly credits"
orgWideAccess = "Org-wide access controls"
overage = "overage"
perMonth = "/month"
popular = "Popular"
premiumAiModels = "Premium AI models"
prioritySupport = "Priority support"
privateDocCloud = "Private Document Cloud"
ragFineTuning = "RAG + fine-tuning"
secureApiAccess = "Secure API access"
selfHostLink = "Review the docs and plans"
selfHostPrompt = "Want to self host?"
standardThroughput = "Standard throughput"
subtitle = "Upgrade to Team for 10x the credits and faster processing."
subtitlePro = "Enable automatic overage billing to never run out of credits."
teamLeaderOnly = "Only team leaders can enable overage billing"
teamSubscription = "Team"
titleExhausted = "You've used your free credits"
titleExhaustedPro = "You have run out of credits"
unlimitedApiAccess = "Unlimited API access"
unlimitedMonthlyCredits = "Site License"
unlimitedSeats = "Unlimited seats"
[credits.modal.features]
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
forRegularWork = "For regular PDF work:"
[credits.insufficient]
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
managedMember = "Please contact your team leader for assistance."
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
teamMember = "Enable overage billing to never run out of credits."
title = "Insufficient Credits"
[printFile]
header = "Print File to Printer"
submit = "Print"
@@ -5542,6 +5628,70 @@ user = "Logged in as"
saas = "Stirling Cloud"
selfhosted = "Self-Hosted"
[settings.planBilling]
currentPlan = "Current Plan"
loading = "Loading billing information..."
notAvailable = "Plan & Billing is only available when connected to Stirling Cloud (SaaS mode)."
title = "Plan & Billing"
[settings.planBilling.billing]
manageBilling = "Manage Billing"
nextBillingDate = "Next billing date"
overageCost = "Current overage cost: {{amount}} ({{credits}} credits)"
[settings.planBilling.credits]
estimatedCost = "Estimated cost: {{amount}}"
freeTierInfo = "Free plan includes {{freeCredits}} credits per month. Upgrade to Team for {{teamCredits}} credits/month and pay-as-you-go overage billing."
included = "{{count}} credits/month (included)"
noOverage = "No overage charges this month. You're using your included {{count}} credits."
overage = "+ {{count}} overage"
overageInfo = "Overage credits are billed at {{price}} per credit. You'll only pay for what you use beyond your monthly allowance."
title = "Credit Usage"
[settings.planBilling.errors]
fetchFailed = "Unable to fetch billing data"
retry = "Retry"
[settings.planBilling.status]
active = "Active"
canceled = "Canceled"
pastDue = "Past Due"
trial = "Trial"
[settings.planBilling.tier]
enterprise = "Enterprise"
enterpriseDescription = "Custom enterprise features and support"
free = "Free"
freeDescription = "50 credits per month"
team = "Team"
teamBadge = "Team"
teamDescription = "500 credits/month included, automatic overage billing for uninterrupted service"
teamTooltipCredits = "Team plan includes {{credits}} credits/month."
teamTooltipFineprint = "Only pay for what you use beyond included credits."
teamTooltipOverage = "Automatic overage billing at {{price}}/credit ensures uninterrupted service."
[settings.planBilling.team]
managedByTeam = "Managed by team"
memberCount = "{{count}} team members"
[settings.planBilling.trial]
daysRemaining = "{{days}} days remaining"
daysRemainingFull = "Your trial ends in {{days}} days"
endDate = "Expires: {{date}}"
endsOn = "Trial ends"
title = "Free Trial Active"
[settings.planBilling.upgrade]
cta = "Upgrade to Team"
featureApi = "API access for automation"
featureCredits = "{{teamCredits}} credits per month (vs {{freeCredits}} on Free)"
featureMembers = "Unlimited team members"
featureSupport = "Priority support"
featureThroughput = "Faster processing throughput"
opensInBrowser = "Opens in browser to complete upgrade"
subtitle = "Upgrade to Team for:"
title = "Upgrade Your Plan"
[settings.developer]
apiKeys = "API Keys"
title = "Developer"
@@ -5666,6 +5816,9 @@ people = "People"
teams = "Teams"
title = "Workspace"
[settings.team]
title = "Team"
[setup]
description = "Get started by choosing how you want to use Stirling PDF"
welcome = "Welcome to Stirling PDF"
@@ -6971,3 +7124,78 @@ cancel = "Cancel"
confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
[team]
cancelInviteError = "Failed to cancel invitation"
confirmCancelInvite = "Are you sure you want to cancel this invitation?"
confirmLeave = "Are you sure you want to leave this team?"
confirmLeaveLeader = "As a team leader, leaving will remove you from the team. Are you sure?"
confirmRemove = "Are you sure you want to remove this member?"
editName = "Edit team name"
inviteCancelled = "Invitation cancelled"
inviteError = "Failed to send invitation"
inviteSent = "Invitation sent successfully"
leader = "Leader"
leaveButton = "Leave Team"
leaveError = "Failed to leave team"
leaveSuccess = "Successfully left team"
loading = "Loading team information..."
memberCount = "{{count}} members"
memberRemoved = "Member removed successfully"
namePlaceholder = "Enter team name"
personal = "Personal"
removeError = "Failed to remove member"
renameError = "Failed to rename team"
renameSuccess = "Team renamed successfully"
[team.invitationBanner]
message = "has invited you to join"
acceptButton = "Accept"
rejectButton = "Decline"
[team.features]
badge = "Team Features"
subtitle = "Collaborate with your team"
title = "Team Collaboration"
viewPlans = "View Plans"
[team.features.billing]
description = "Manage billing and subscriptions"
title = "Billing Management"
[team.features.credits]
description = "Shared credit pool for all members"
title = "Shared Credits"
[team.features.dashboard]
description = "View team activity and usage"
title = "Team Dashboard"
[team.features.invite]
description = "Add members to your team"
title = "Invite Members"
[team.invite]
cancelLabel = "Cancel"
invalidEmail = "Invalid email address"
placeholder = "Enter email address"
sendButton = "Send Invitation"
title = "Invite Team Member"
[team.members]
emailColumn = "Email"
empty = "No team members yet"
nameColumn = "Name"
pending = "Pending"
remove = "Remove"
roleColumn = "Role"
title = "Team Members"
[team.upgrade]
button = "Upgrade to Team"
description = "Unlock team collaboration features"
title = "Upgrade to Team Plan"
@@ -0,0 +1,12 @@
/**
* Core stub for QuickAccessBar footer extensions
* Desktop build overrides this with actual credit counter implementation
*/
interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
return null;
}
@@ -0,0 +1,11 @@
interface CloudBadgeProps {
className?: string;
}
/**
* Stub component for cloud badge (desktop override provides real implementation)
* In web builds, this returns null since cloud routing is desktop-only
*/
export function CloudBadge(_props: CloudBadgeProps) {
return null; // Stub - does nothing in web builds
}
@@ -27,6 +27,7 @@ import {
getActiveNavButton,
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@@ -265,6 +266,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
<QuickAccessBarFooterExtensions className="quick-access-footer" />
{/* Bottom section */}
<Stack gap="lg" align="stretch">
{bottomButtons.map((buttonConfig, index) => {
@@ -0,0 +1,9 @@
/**
* Core stub for SaaS Teams Section
* Desktop layer provides the real implementation
* This component only appears in SaaS mode
*/
export function SaaSTeamsSection() {
// Core stub - return null (no team management in web builds)
return null;
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaasPlanSection
* This component returns null in non-desktop builds
* The desktop layer shadows this with the real implementation
*/
export function SaasPlanSection() {
return null;
}
@@ -0,0 +1,13 @@
/**
* Core stub for Credit Exhausted Modal
* Desktop build overrides this with actual modal implementation
*/
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
return null;
}
@@ -0,0 +1,15 @@
/**
* Core stub for Insufficient Credits Modal
* Desktop build overrides this with actual modal implementation
*/
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) {
return null;
}
@@ -9,6 +9,7 @@ import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -63,7 +64,18 @@ const ConvertSettings = ({
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
// Get comprehensive conversion status (availability + cloud routing)
const conversionStatus = useConversionCloudStatus();
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
const conversionKey = `${fromExt}-${toExt}`;
// In desktop SaaS mode, check combined availability (local OR SaaS)
if (conversionStatus.availability[conversionKey] !== undefined) {
return conversionStatus.availability[conversionKey];
}
// Fallback to local-only check (web mode or desktop non-SaaS mode)
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
@@ -71,6 +83,10 @@ const ConvertSettings = ({
return isAvailable;
};
const doesConversionUseCloud = (fromExt: string, toExt: string): boolean => {
return conversionStatus.cloudStatus[`${fromExt}-${toExt}`] || false;
};
// Enhanced FROM options with endpoint availability
const enhancedFromOptions = useMemo(() => {
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
@@ -107,18 +123,20 @@ const ConvertSettings = ({
}
return filteredOptions;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
// Enhanced TO options with endpoint availability
// Enhanced TO options with endpoint availability and cloud status
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
const enhanced = availableOptions.map(option => {
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
const usesCloud = doesConversionUseCloud(parameters.fromExtension, option.value);
return {
...option,
enabled
enabled,
usesCloud
};
});
@@ -128,7 +146,7 @@ const ConvertSettings = ({
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface FormatOption {
@@ -8,6 +9,7 @@ interface FormatOption {
label: string;
group: string;
enabled?: boolean;
usesCloud?: boolean;
}
interface GroupedFormatDropdownProps {
@@ -145,10 +147,20 @@ const GroupedFormatDropdown = ({
fontSize: '0.75rem',
height: '2rem',
padding: '0 0.75rem',
flexShrink: 0
flexShrink: 0,
position: 'relative'
}}
>
{option.label}
{option.usesCloud && (
<CloudOutlinedIcon
style={{
fontSize: '0.625rem',
marginLeft: '0.25rem',
opacity: 0.7
}}
/>
)}
</Button>
))}
</Group>
@@ -1,7 +1,8 @@
import { Button } from '@mantine/core';
import { Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { CloudBadge } from '@app/components/shared/CloudBadge';
export interface OperationButtonProps {
onClick?: () => void;
@@ -14,6 +15,7 @@ export interface OperationButtonProps {
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
showCloudBadge?: boolean;
'data-testid'?: string;
'data-tour'?: string;
}
@@ -29,6 +31,7 @@ const OperationButton = ({
fullWidth = false,
mt = 'md',
type = 'button',
showCloudBadge = false,
'data-testid': dataTestId,
'data-tour': dataTour
}: OperationButtonProps) => {
@@ -54,12 +57,17 @@ const OperationButton = ({
color={color}
data-testid={dataTestId}
data-tour={dataTour}
style={{ minHeight: '2.5rem' }}
style={{ minHeight: '2.5rem', position: 'relative' }}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
{showCloudBadge && (
<Box style={{ position: 'absolute', top: 4, right: 4 }}>
<CloudBadge />
</Box>
)}
</Button>
);
@@ -38,6 +38,7 @@ export interface ExecuteButtonConfig {
isVisible?: boolean;
disabled?: boolean;
testId?: string;
showCloudBadge?: boolean;
}
export interface ReviewStepConfig<TParams = unknown> {
@@ -105,6 +106,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
data-testid={config.executeButton.testId}
data-tour="run-button"
/>
@@ -14,6 +14,8 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { CloudBadge } from "@app/components/shared/CloudBadge";
import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus";
interface ToolButtonProps {
id: ToolId;
@@ -38,6 +40,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const { getToolNavigation } = useToolNavigation();
const fav = isFavorite(id as ToolId);
// Check if this tool will route to SaaS backend (desktop only)
const endpointName = tool.endpoints?.[0];
const usesCloud = useToolCloudStatus(endpointName);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
if (tool.link) {
@@ -98,6 +104,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
{t('toolPanel.alpha', 'Alpha')}
</Badge>
)}
{usesCloud && !isUnavailable && (
<CloudBadge />
)}
</div>
{matchedSynonym && (
<span style={{
@@ -0,0 +1,76 @@
import { createContext, ReactNode } from 'react';
/**
* Core stub for SaaS Team Context
* Desktop layer provides the real implementation
*/
export interface TeamMember {
id: string;
username: string;
email: string;
role: string;
joinedAt: number;
}
export interface TeamInvitation {
id: string;
email: string;
invitedAt: number;
invitedBy: string;
}
export interface Team {
id: string;
name: string;
isPersonal: boolean;
createdAt: number;
}
export interface SaaSTeamContextValue {
currentTeam: Team | null;
teams: Team[];
teamMembers: TeamMember[];
teamInvitations: TeamInvitation[];
receivedInvitations: TeamInvitation[];
isTeamLeader: boolean;
isPersonalTeam: boolean;
loading: boolean;
inviteUser: (teamId: string, email: string) => Promise<void>;
acceptInvitation: (invitationId: string) => Promise<void>;
rejectInvitation: (invitationId: string) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
removeMember: (teamId: string, memberId: string) => Promise<void>;
leaveTeam: (teamId: string) => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextValue | undefined>(undefined);
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
// Core stub - no-op implementation
return children;
}
export function useSaaSTeam(): SaaSTeamContextValue {
// Core stub - return default values
return {
currentTeam: null,
teams: [],
teamMembers: [],
teamInvitations: [],
receivedInvitations: [],
isTeamLeader: false,
isPersonalTeam: true,
loading: false,
inviteUser: async () => {},
acceptInvitation: async () => {},
rejectInvitation: async () => {},
cancelInvitation: async () => {},
removeMember: async () => {},
leaveTeam: async () => {},
refreshTeams: async () => {},
};
}
export { SaaSTeamContext };
@@ -0,0 +1,10 @@
/**
* Core stub for SaasBillingContext.
* Returns null in web/core builds — desktop layer shadows this with the real implementation.
* See: frontend/src/desktop/contexts/SaasBillingContext.tsx
*/
export const useSaaSBilling = (): null => null;
export function SaasBillingProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
import { getEndpointUrl, getEndpointName, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
import { useToolCloudStatus } from '@app/hooks/useToolCloudStatus';
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
@@ -195,9 +196,21 @@ export const convertOperationConfig = {
defaultParameters,
} as const;
export const useConvertOperation = () => {
export const useConvertOperation = (parameters?: ConvertParameters) => {
const { t } = useTranslation();
// Calculate current endpoint name for cloud detection
const currentEndpointName = useMemo(() => {
if (!parameters?.fromExtension || !parameters?.toExtension) return undefined;
// Map PDF/X to use PDF/A endpoint (same as in convertProcessor)
const actualToExtension = parameters.toExtension === 'pdfx' ? 'pdfa' : parameters.toExtension;
return getEndpointName(parameters.fromExtension, actualToExtension);
}, [parameters?.fromExtension, parameters?.toExtension]);
// Check if current conversion will use cloud
const willUseCloud = useToolCloudStatus(currentEndpointName);
const customConvertProcessor = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
@@ -205,7 +218,7 @@ export const useConvertOperation = () => {
return convertProcessor(parameters, selectedFiles);
}, []);
return useToolOperation<ConvertParameters>({
const operation = useToolOperation<ConvertParameters>({
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
@@ -218,4 +231,10 @@ export const useConvertOperation = () => {
return t("convert.errorConversion", "An error occurred while converting the file.");
},
});
// Override willUseCloud with our calculated value
return {
...operation,
willUseCloud,
};
};
@@ -15,6 +15,8 @@ import { createNewStirlingFileStub } from '@app/types/fileContext';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
import { useWillUseCloud } from '@app/hooks/useWillUseCloud';
import { useCreditCheck } from '@app/hooks/useCreditCheck';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@@ -147,6 +149,7 @@ export interface ToolOperationHook<TParams = void> {
status: string;
errorMessage: string | null;
progress: ProcessingProgress | null;
willUseCloud?: boolean;
// Actions
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
@@ -183,6 +186,16 @@ export const useToolOperation = <TParams>(
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources();
const { checkCredits } = useCreditCheck(config.operationType);
// Determine endpoint for cloud usage check
const endpointString = config.toolType !== ToolType.custom && config.endpoint
? (typeof config.endpoint === 'function'
? (config.defaultParameters ? config.endpoint(config.defaultParameters) : undefined)
: config.endpoint)
: undefined;
const willUseCloud = useWillUseCloud(endpointString);
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
@@ -217,7 +230,24 @@ export const useToolOperation = <TParams>(
return;
}
const backendReady = await ensureBackendReady();
// Get endpoint (static or dynamic) for backend readiness check
const endpoint = config.customProcessor
? undefined // Custom processors may not have endpoints
: typeof config.endpoint === 'function'
? config.endpoint(params)
: config.endpoint;
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
if (willUseCloud && endpoint) {
const creditError = await checkCredits();
if (creditError !== null) {
actions.setError(creditError);
return;
}
}
// Backend readiness check (will skip for SaaS-routed endpoints)
const backendReady = await ensureBackendReady(endpoint);
if (!backendReady) {
actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.'));
return;
@@ -507,7 +537,7 @@ export const useToolOperation = <TParams>(
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]);
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, willUseCloud, checkCredits]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
@@ -591,6 +621,7 @@ export const useToolOperation = <TParams>(
status: state.status,
errorMessage: state.errorMessage,
progress: state.progress,
willUseCloud,
// Actions
executeOperation,
@@ -0,0 +1,21 @@
/**
* Comprehensive conversion status data
*/
export interface ConversionStatus {
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
}
/**
* Core stub for conversion cloud status checking
* Desktop layer provides the real implementation
* In web builds, always returns empty objects (no cloud routing)
*/
export function useConversionCloudStatus(): ConversionStatus {
return {
availability: {},
cloudStatus: {},
localOnly: {},
}; // Core stub - web builds don't use cloud
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Core stub for credit checking before cloud operations
* Desktop layer shadows this with the real implementation
* In web builds, always allows the operation (no credit system)
*/
export function useCreditCheck(_operationType?: string) {
return {
checkCredits: async (): Promise<string | null> => null, // null = allowed
};
}
@@ -0,0 +1,8 @@
/**
* Core stub for tool cloud status checking
* Desktop layer provides the real implementation
* In web builds, always returns false (no cloud routing)
*/
export function useToolCloudStatus(_endpointName?: string): boolean {
return false; // Core stub - web builds don't use cloud
}
@@ -0,0 +1,8 @@
/**
* Core stub for cloud usage detection
* Desktop layer provides the real implementation
* In web builds, always returns false since there's no cloud routing
*/
export function useWillUseCloud(_endpoint?: string): boolean {
return false; // Core stub - web builds don't use cloud
}
@@ -1,7 +1,8 @@
/**
* Default backend readiness guard (web builds do not need to wait for
* anything outside the browser, so we always report ready).
* @param _endpoint - Optional endpoint path (not used in web builds)
*/
export async function ensureBackendReady(): Promise<boolean> {
export async function ensureBackendReady(_endpoint?: string): Promise<boolean> {
return true;
}
+5 -76
View File
@@ -1,83 +1,9 @@
// frontend/src/services/httpErrorHandler.ts
import axios from 'axios';
import { alert } from '@app/components/toast';
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from '@app/services/errorUtils';
import { showSpecialErrorToast } from '@app/services/specialErrorToasts';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
import { handleSaaSError } from '@app/services/saasErrorInterceptor';
import { clampText, extractAxiosErrorMessage } from '@app/services/httpErrorUtils';
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record<string, number> = {};
@@ -126,6 +52,9 @@ export async function handleHttpError(error: any): Promise<boolean> {
console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
return true;
}
if (handleSaaSError(error)) return true;
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
@@ -0,0 +1,76 @@
import axios from 'axios';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
export function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
export function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaaS backend error interception.
* Desktop layer shadows this with the real implementation.
* In web builds there are no SaaS requests, so this always returns false.
*/
export function handleSaaSError(_error: unknown): boolean {
return false;
}
+1 -1
View File
@@ -19,7 +19,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const convertParams = useConvertParameters();
const convertOperation = useConvertOperation();
const convertOperation = useConvertOperation(convertParams.parameters);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@supabase/supabase-js';
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
/**
* Supabase client for desktop application
* Used to call Supabase edge functions for billing and other SaaS features
*
* Note: Desktop uses authService for authentication (JWT stored in Tauri secure store),
* but this client is needed for calling Supabase edge functions like get-usage-billing
*/
if (!STIRLING_SAAS_URL) {
console.warn('[Desktop Supabase] VITE_SAAS_SERVER_URL not configured - SaaS features will not work');
}
if (!SUPABASE_KEY) {
console.warn('[Desktop Supabase] VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY not configured - SaaS features will not work');
}
export const supabase = createClient(
STIRLING_SAAS_URL || '',
SUPABASE_KEY || '',
{
auth: {
persistSession: false, // Desktop manages auth via authService + Tauri secure store
autoRefreshToken: false, // Desktop manually refreshes tokens via authService
detectSessionInUrl: false, // Desktop uses deep links, not URL hash fragments
},
}
);
@@ -10,8 +10,27 @@ import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { authService } from '@app/services/authService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauri } from '@tauri-apps/api/core';
import { SaaSTeamProvider } from '@app/contexts/SaaSTeamContext';
import { SaasBillingProvider } from '@app/contexts/SaasBillingContext';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { CreditModalBootstrap } from '@app/components/shared/modals/CreditModalBootstrap';
// Common tool endpoints to preload for faster first-use
const COMMON_TOOL_ENDPOINTS = [
'/api/v1/misc/compress-pdf',
'/api/v1/general/merge-pdfs',
'/api/v1/general/split-pages',
'/api/v1/convert/pdf/img',
'/api/v1/convert/img/pdf',
'/api/v1/general/rotate-pdf',
'/api/v1/misc/add-watermark',
'/api/v1/security/add-password',
'/api/v1/security/remove-password',
'/api/v1/general/extract-pages',
];
/**
* Desktop application providers
@@ -53,6 +72,39 @@ export function AppProviders({ children }: { children: ReactNode }) {
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability after backend is healthy
useEffect(() => {
if (!shouldMonitorBackend) {
return; // Only preload in SaaS mode with bundled backend
}
const preloadEndpoints = async () => {
const backendHealthy = tauriBackendService.isBackendHealthy();
if (backendHealthy) {
console.debug('[AppProviders] Preloading common tool endpoints');
await endpointAvailabilityService.preloadEndpoints(
COMMON_TOOL_ENDPOINTS,
tauriBackendService.getBackendUrl()
);
console.debug('[AppProviders] Endpoint preloading complete');
}
};
// Subscribe to backend status changes
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
preloadEndpoints();
}
});
// Also check immediately in case backend is already healthy
if (tauriBackendService.isBackendHealthy()) {
preloadEndpoints();
}
return unsubscribe;
}, [shouldMonitorBackend]);
useEffect(() => {
if (!authChecked) {
return;
@@ -148,10 +200,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
autoFetch: false,
}}
>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
{children}
<SaaSTeamProvider>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
<CreditModalBootstrap />
{children}
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
</ProprietaryAppProviders>
);
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
import { TeamInvitationBanner } from '@app/components/shared/TeamInvitationBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
@@ -9,6 +10,7 @@ export function DesktopBannerInitializer() {
useEffect(() => {
setBanner(
<>
<TeamInvitationBanner />
<UpgradeBanner />
<DefaultAppBanner />
</>,
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { Box, Text, Stack } from '@mantine/core';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
/**
* Desktop credit counter displayed in QuickAccessBar footer
* Shows when user is in SaaS mode with low credits (<20)
*/
interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFooterExtensionsProps) {
const { creditBalance, loading, isManagedTeamMember } = useSaaSBilling();
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Check connection mode and authentication status
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsAuthenticated(auth);
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkMode);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
});
return unsubscribe;
}, []);
// Don't show credit counter if:
// - Not in SaaS mode
// - Not authenticated
// - Still loading billing data
// - User is a managed team member (unlimited credits)
// - Credits >= 20 (only show when low)
if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= 20) {
return null;
}
const handleClick = () => {
// Dispatch low credits event to open upgrade modal
window.dispatchEvent(new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { source: 'quickAccessBar' }
}));
};
return (
<Box className={className} style={{ padding: '0.5rem', cursor: 'pointer' }} onClick={handleClick}>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed" fw={500}>
{creditBalance} {creditBalance === 1 ? 'credit' : 'credits'}
</Text>
<Text size="xs" c="dimmed" style={{ textDecoration: 'underline' }}>
Upgrade
</Text>
</Stack>
</Box>
);
}
@@ -0,0 +1,33 @@
import { Badge, Tooltip } from '@mantine/core';
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
import { useTranslation } from 'react-i18next';
interface CloudBadgeProps {
className?: string;
}
/**
* Badge component to indicate that a tool uses cloud/SaaS backend processing
* Displayed on tool cards when the tool will be routed to the SaaS backend
* instead of the local bundled backend.
*/
export function CloudBadge({ className }: CloudBadgeProps) {
const { t } = useTranslation();
return (
<Tooltip
label={t('cloudBadge.tooltip', 'This operation will use your cloud credits')}
position="top"
withArrow
>
<Badge
className={className}
leftSection={<CloudOutlinedIcon sx={{ fontSize: 12 }} />}
variant="light"
color="blue"
size="xs"
>
</Badge>
</Tooltip>
);
}
@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { Button, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { connectionModeService } from '@app/services/connectionModeService';
export function TeamInvitationBanner() {
const { t } = useTranslation();
const { receivedInvitations, acceptInvitation, rejectInvitation } = useSaaSTeam();
const { refreshBilling } = useSaaSBilling();
const [processing, setProcessing] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [connectionMode, setConnectionMode] = useState<string | null>(null);
// Load connection mode on mount
useEffect(() => {
connectionModeService.getCurrentMode().then(mode => setConnectionMode(mode));
}, []);
// Accept invitation handler
const handleAccept = async () => {
const invitation = receivedInvitations[0];
if (!invitation) return;
setProcessing(true);
try {
await acceptInvitation(invitation.invitationToken);
console.log('[TeamInvitationBanner] Invitation accepted successfully:', invitation.teamName);
// Wait briefly for backend to process team membership update
await new Promise(resolve => setTimeout(resolve, 1000));
// Refresh billing after joining team (tier may have changed)
console.log('[TeamInvitationBanner] Refreshing billing after team join...');
await refreshBilling();
setDismissed(true);
} catch (error) {
console.error('[TeamInvitationBanner] Failed to accept invitation:', error);
} finally {
setProcessing(false);
}
};
// Reject invitation handler
const handleReject = async () => {
const invitation = receivedInvitations[0];
if (!invitation) return;
setProcessing(true);
try {
await rejectInvitation(invitation.invitationToken);
console.log('[TeamInvitationBanner] Invitation rejected');
setDismissed(true);
} catch (error) {
console.error('[TeamInvitationBanner] Failed to reject invitation:', error);
} finally {
setProcessing(false);
}
};
// Visibility logic
const shouldShow =
connectionMode === 'saas' &&
!dismissed &&
receivedInvitations.length > 0;
if (!shouldShow) return null;
const invitation = receivedInvitations[0]; // Show first invitation
const message = (
<Text component="span" size="sm" fw={500} style={{ color: 'rgba(255, 255, 255, 0.95)' }}>
<strong>{invitation.inviterEmail}</strong> {t('team.invitationBanner.message', 'has invited you to join')}{' '}
<strong>{invitation.teamName}</strong>
</Text>
);
const actionButtons = (
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
color="gray"
size="xs"
onClick={handleAccept}
loading={processing}
leftSection={<LocalIcon icon="check" width="0.9rem" height="0.9rem" style={{ color: 'var(--mantine-color-dark-9)' }} />}
styles={{
label: {
color: 'var(--mantine-color-dark-9)',
},
}}
>
{t('team.invitationBanner.acceptButton', 'Accept')}
</Button>
<Button
variant="subtle"
size="xs"
onClick={handleReject}
loading={processing}
style={{ color: 'rgba(255, 255, 255, 0.7)' }}
>
{t('team.invitationBanner.rejectButton', 'Decline')}
</Button>
</Group>
);
return (
<InfoBanner
icon="mail"
message={
<Group justify="space-between" align="center" wrap="nowrap" style={{ width: '100%' }}>
{message}
{actionButtons}
</Group>
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { saasBillingService } from '@app/services/saasBillingService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser';
type CheckoutState = {
status: 'idle' | 'loading' | 'opened' | 'refreshing' | 'error';
error?: string;
sessionPlanId?: string;
};
interface SaaSStripeCheckoutProps {
opened: boolean;
onClose: () => void;
planId: string | null;
onSuccess?: () => void;
}
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
opened,
onClose,
planId,
onSuccess
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const createCheckoutSession = async () => {
if (!planId) {
setState({ status: 'error', error: 'No plan selected' });
return;
}
try {
setState({ status: 'loading' });
// Map UI plan IDs to Stripe plan IDs
const stripePlanId = planId === 'team' ? 'pro' : planId;
// Open checkout in browser (returns void, opens browser window)
await saasBillingService.openCheckout(
stripePlanId as 'pro',
window.location.origin
);
setState({
status: 'opened',
sessionPlanId: planId
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
console.error('[SaaSStripeCheckout] Error creating checkout:', err);
setState({
status: 'error',
error: errorMessage
});
}
};
const handleRefreshClick = async () => {
console.log('[SaaSStripeCheckout] User requested refresh after checkout');
setState({ ...state, status: 'refreshing' });
// Give Stripe webhooks a moment to process (2-3 seconds)
await new Promise(resolve => setTimeout(resolve, 2000));
// Trigger the refresh
if (onSuccess) {
await onSuccess();
}
// Close modal after refresh
onClose();
};
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
onClose();
};
// Initialize checkout when modal opens or plan changes
useEffect(() => {
if (opened) {
// Check if we need a new session (first time or plan changed)
const needsNewSession =
state.status === 'idle' ||
!state.sessionPlanId ||
state.sessionPlanId !== planId;
if (needsNewSession) {
console.log('[SaaSStripeCheckout] Opening checkout in browser for plan:', planId);
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: 'idle', error: undefined, sessionPlanId: undefined });
}
}, [opened, planId]);
const renderContent = () => {
switch (state.status) {
case 'loading':
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</div>
);
case 'opened':
return (
<Alert color="blue" title={t('payment.checkoutOpened', 'Checkout Opened in Browser')} icon={<OpenInBrowserIcon />}>
<Stack gap="md">
<Text size="sm">
{t('payment.checkoutInstructions', 'Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.')}
</Text>
<Button
variant="filled"
color="blue"
onClick={handleRefreshClick}
fullWidth
>
{t('payment.refreshBilling', 'I\'ve Completed Payment - Refresh Billing')}
</Button>
<Button
variant="subtle"
onClick={handleClose}
fullWidth
>
{t('payment.closeLater', 'I\'ll Do This Later')}
</Button>
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
const getPlanName = () => {
if (planId === 'team') return t('plan.team.name', 'Team');
if (planId === 'enterprise') return t('plan.enterprise.name', 'Enterprise');
return t('plan.free.name', 'Free');
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: getPlanName() })}
</Text>
</div>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
@@ -1,7 +1,12 @@
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react';
import { useConfigNavSections as useProprietaryConfigNavSections, createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
import { ConnectionSettings } from '@app/components/ConnectionSettings';
import { SaasPlanSection } from '@app/components/shared/config/configSections/SaasPlanSection';
import { SaaSTeamsSection } from '@app/components/shared/config/configSections/SaaSTeamsSection';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
/**
* Hook version of desktop config nav sections with proper i18n support
@@ -13,11 +18,54 @@ export const useConfigNavSections = (
): ConfigNavSection[] => {
const { t } = useTranslation();
// Check if in SaaS mode and authenticated (for Team section visibility)
const [isSaasMode, setIsSaasMode] = useState<boolean>(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
useEffect(() => {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsAuthenticated(auth);
};
checkAccess();
// Subscribe to connection mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
}, []);
// Subscribe to auth changes
useEffect(() => {
const unsubscribe = authService.subscribeToAuth((status) => {
setIsAuthenticated(status === 'authenticated');
});
return unsubscribe;
}, []);
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
// Identifies self-hosted admin sections by their first item's stable key.
// Using item keys avoids dependency on translated section titles (#17).
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
'people', // Workspace section
'adminGeneral', // Configuration section
'adminSecurity', // Security & Authentication section
'adminPlan', // Licensing & Analytics section
'adminLegal', // Policies & Privacy section
]);
// Build the result array explicitly instead of splice with hardcoded indices (#18).
const result: ConfigNavSection[] = [];
// Preferences is always first
if (sections.length > 0) result.push(sections[0]);
// Connection Mode always sits immediately after Preferences
result.push({
title: t('settings.connection.title', 'Connection Mode'),
items: [
{
@@ -29,7 +77,42 @@ export const useConfigNavSections = (
],
});
return sections;
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
result.push({
title: t('settings.planBilling.title', 'Plan & Billing'),
items: [
{
key: 'planBilling',
label: t('settings.planBilling.title', 'Plan & Billing'),
icon: 'credit-card',
component: <SaasPlanSection />,
},
],
});
result.push({
title: t('settings.team.title', 'Team'),
items: [
{
key: 'teams',
label: t('settings.team.title', 'Team'),
icon: 'groups-rounded',
component: <SaaSTeamsSection />,
},
],
});
}
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
for (const section of sections.slice(1)) {
const firstItemKey = section.items[0]?.key;
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
continue;
}
result.push(section);
}
return result;
};
/**
@@ -59,5 +142,18 @@ export const createConfigNavSections = (
],
});
// Add Plan & Billing section (after Connection Mode)
sections.splice(2, 0, {
title: 'Plan & Billing',
items: [
{
key: 'planBilling',
label: 'Plan & Billing',
icon: 'credit-card',
component: <SaasPlanSection />,
},
],
});
return sections;
};
@@ -0,0 +1,513 @@
import React, { useState, useEffect } from 'react';
import { Button, TextInput, Group, Text, Stack, Alert, Table, Badge, ActionIcon, Menu, List, ThemeIcon, Modal, CloseButton, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import apiClient from '@app/services/apiClient';
/**
* Desktop SaaS Teams Section
* Allows team management for users connected to SaaS backend
* CRITICAL: Only shown when in SaaS mode (enforced by navigation)
*/
export function SaaSTeamsSection() {
const { t } = useTranslation();
const {
currentTeam,
teamMembers,
teamInvitations,
isTeamLeader,
isPersonalTeam,
inviteUser,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
} = useSaaSTeam();
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== 'free';
const [inviteEmail, setInviteEmail] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
useEffect(() => {
// Refresh immediately on mount
refreshTeams();
// Then refresh every 10 seconds
const interval = setInterval(() => {
refreshTeams();
}, 10000);
return () => clearInterval(interval);
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'planBilling' } }));
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviting(true);
setError(null);
setSuccess(null);
try {
await inviteUser(inviteEmail);
setSuccess(t('team.inviteSent', 'Invitation sent to {{email}}', { email: inviteEmail }));
setInviteEmail('');
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.inviteError', 'Failed to send invitation'));
} finally {
setInviting(false);
}
};
const handleRemove = async (memberId: number, memberEmail: string) => {
if (!window.confirm(t('team.confirmRemove', 'Remove {{email}} from the team?', { email: memberEmail }))) return;
try {
await removeMember(memberId);
setSuccess(t('team.memberRemoved', 'Member removed successfully'));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.removeError', 'Failed to remove member'));
}
};
const handleCancelInvitation = async (invitationId: number, email: string) => {
if (!window.confirm(t('team.confirmCancelInvite', 'Cancel invitation for {{email}}?', { email }))) return;
try {
await cancelInvitation(invitationId);
setSuccess(t('team.inviteCancelled', 'Invitation for {{email}} cancelled', { email }));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(error.response?.data?.error || t('team.cancelInviteError', 'Failed to cancel invitation'));
}
};
const handleStartRename = () => {
if (currentTeam) {
setNewTeamName(currentTeam.name);
setIsEditingName(true);
}
};
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName('');
};
const handleRenameSubmit = async () => {
if (!currentTeam || !newTeamName.trim()) return;
setRenamingTeam(true);
setError(null);
try {
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, {
newName: newTeamName.trim(),
});
setSuccess(t('team.renameSuccess', 'Team renamed successfully'));
setIsEditingName(false);
await refreshTeams();
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.renameError', 'Failed to rename team'));
} finally {
setRenamingTeam(false);
}
};
const handleLeaveTeam = async () => {
if (!currentTeam || isPersonalTeam) return;
const confirmMessage = isTeamLeader
? t('team.confirmLeaveLeader', 'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.', { name: currentTeam.name })
: t('team.confirmLeave', 'Are you sure you want to leave "{{name}}"?', { name: currentTeam.name });
if (!window.confirm(confirmMessage)) return;
try {
await leaveTeam();
setSuccess(t('team.leaveSuccess', 'Successfully left team'));
} catch (err) {
const error = err as { response?: { data?: { error?: string } }; message?: string };
setError(error.response?.data?.error || error.message || t('team.leaveError', 'Failed to leave team'));
}
};
if (!currentTeam) {
return (
<Alert color="gray">
<Text>{t('team.loading', 'Loading team information...')}</Text>
</Alert>
);
}
return (
<Stack gap="lg">
{/* Header */}
<div>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
<Group gap="xs" align="center">
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t('team.namePlaceholder', 'Team name')}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit();
if (e.key === 'Escape') handleCancelRename();
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">{currentTeam.name}</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t('team.editName', 'Edit team name')}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && <Badge color='blue'>{t('team.leader', 'LEADER')}</Badge>}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">{t('team.personal', 'Personal')}</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t('team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
</Text>
)}
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={<LocalIcon icon="logout" width="1rem" height="1rem" />}
>
{t('team.leaveButton', 'Leave Team')}
</Button>
)}
</Group>
</div>
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert color="blue" icon={<LocalIcon icon="info" width={16} height={16} />}>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">{t('team.upgrade.title', 'Upgrade to Pro to unlock team features')}</Text>
<Text size="xs" c="dimmed" mt={2}>
{t('team.upgrade.description', 'Invite members, share credits, and more.')}{' '}
<Anchor size="xs" onClick={() => setFeaturesModalOpened(true)} style={{ cursor: 'pointer' }}>
{t('common.learnMore', 'Learn more')}
</Anchor>
</Text>
</div>
<Button
size="sm"
variant="light"
onClick={navigateToPlan}
>
{t('team.upgrade.button', 'Upgrade to Pro')}
</Button>
</Group>
</Alert>
)}
{/* Team Features Modal */}
<Modal
opened={featuresModalOpened}
onClose={() => setFeaturesModalOpened(false)}
size="md"
centered
padding="xl"
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">{t('team.features.badge', 'PRO FEATURE')}</Badge>
<Text size="xl" fw={700} ta="center">
{t('team.features.title', 'Team Collaboration')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('team.features.subtitle', 'Upgrade to Pro and unlock powerful team features')}
</Text>
</Stack>
{/* Features List */}
<List
spacing="md"
size="sm"
icon={
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
<LocalIcon icon="check" width={14} height={14} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={500}>{t('team.features.invite.title', 'Invite team members')}</Text>
<Text size="xs" c="dimmed">{t('team.features.invite.description', 'Add unlimited users with additional seat purchases')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.credits.title', 'Share credits across your team')}</Text>
<Text size="xs" c="dimmed">{t('team.features.credits.description', 'Pool resources for collaborative work')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.dashboard.title', 'Team management dashboard')}</Text>
<Text size="xs" c="dimmed">{t('team.features.dashboard.description', 'Control permissions, monitor usage, and manage members')}</Text>
</List.Item>
<List.Item>
<Text fw={500}>{t('team.features.billing.title', 'Centralized billing')}</Text>
<Text size="xs" c="dimmed">{t('team.features.billing.description', 'One invoice for all team seats and usage')}</Text>
</List.Item>
</List>
{/* CTA Button */}
<Button
size="md"
fullWidth
onClick={() => {
setFeaturesModalOpened(false);
navigateToPlan();
}}
>
{t('team.features.viewPlans', 'View Pro Plans')}
</Button>
</Stack>
</div>
</Modal>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
{success && (
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
{success}
</Alert>
)}
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">{t('team.invite.title', 'Invite Team Member')}</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t('team.invite.placeholder', '[email protected]')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) ? t('team.invite.invalidEmail', 'Invalid email format') : undefined}
/>
<Button
type="submit"
loading={inviting}
disabled={!inviteEmail.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)}
>
{t('team.invite.sendButton', 'Send Invite')}
</Button>
</Group>
</form>
</div>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">{t('team.members.title', 'Team Members')}</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.nameColumn', 'Name')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.emailColumn', 'Email')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('team.members.roleColumn', 'Role')}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t('team.members.empty', 'No team members yet.')}
</Text>
</Table.Td>
</Table.Tr>
) : (
<>
{/* Active Members */}
{teamMembers.map((member) => (
<Table.Tr key={`member-${member.id}`}>
<Table.Td>
<Text size="sm" fw={500}>
{member.username}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{member.email}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={member.role === 'LEADER' ? 'blue' : undefined}
style={member.role !== 'LEADER' ? {
backgroundColor: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
} : undefined}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== 'LEADER' && (
<Menu position="bottom-end" withinPortal zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Menu.Target>
<ActionIcon variant="subtle">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemove(member.id, member.email)}
>
{t('team.members.remove', 'Remove from Team')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
)}
</Table.Tr>
))}
{/* Pending Invitations */}
{teamInvitations
.filter(inv => inv.status === 'PENDING')
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split('@')[0]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{invitation.inviteeEmail}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t('team.members.pending', 'PENDING')}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)}
aria-label={t('team.invite.cancelLabel', 'Cancel invitation')}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Table.Td>
)}
</Table.Tr>
))}
</>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}
@@ -0,0 +1,225 @@
import { useEffect, useState } from 'react';
import { Stack, Loader, Alert, Button, Center, Text, Flex } from '@mantine/core';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { useSaaSPlans } from '@app/hooks/useSaaSPlans';
import { connectionModeService } from '@app/services/connectionModeService';
import { SaaSCheckoutProvider } from '@app/contexts/SaaSCheckoutContext';
import { ActiveSubscriptionCard } from '@app/components/shared/config/configSections/plan/ActiveSubscriptionCard';
import { SaaSAvailablePlansSection } from '@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection';
/**
* SaaS Plan & Billing section
* Shows subscription status, billing information, and usage metrics
* Only visible when connected to SaaS
*/
export function SaasPlanSection() {
const { t } = useTranslation();
const [isSaasMode, setIsSaasMode] = useState<boolean | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
// Billing context
const {
subscription,
usage,
tier,
isTrialing,
trialDaysRemaining,
loading,
error,
refreshBilling,
price,
currency,
isManagedTeamMember,
openBillingPortal,
} = useSaaSBilling();
// Team data for ActiveSubscriptionCard
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
// Plans data
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans('usd');
// Check connection mode on mount
useEffect(() => {
const checkMode = async () => {
const mode = await connectionModeService.getCurrentMode();
setIsSaasMode(mode === 'saas');
};
checkMode();
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(async (config) => {
setIsSaasMode(config.mode === 'saas');
});
return unsubscribe;
}, []);
// Handle "Manage Billing" button click
const handleManageBilling = async () => {
setIsOpeningPortal(true);
try {
// Context handles opening portal and auto-refresh
await openBillingPortal();
} catch (error) {
console.error('[SaasPlanSection] Failed to open billing portal:', error);
} finally {
setIsOpeningPortal(false);
}
};
// Format date for trial end
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Don't render anything if not in SaaS mode
if (isSaasMode === false) {
return (
<Center p="xl">
<Alert color="blue" variant="light" icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}>
<Text size="sm">
{t(
'settings.planBilling.notAvailable',
'Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).'
)}
</Text>
</Alert>
</Center>
);
}
// Loading state while checking mode
if (isSaasMode === null) {
return (
<Center p="xl">
<Loader size="sm" />
</Center>
);
}
// Loading state while fetching billing/team data
// Note: loading already includes teamLoading from billing context
if (loading) {
return (
<Center p="xl">
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('settings.planBilling.loading', 'Loading billing information...')}
</Text>
</Stack>
</Center>
);
}
// Error state
if (error) {
return (
<Center p="xl">
<Alert
color="red"
variant="light"
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')}
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
variant="light"
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
onClick={refreshBilling}
size="xs"
>
{t('settings.planBilling.errors.retry', 'Retry')}
</Button>
</Stack>
</Alert>
</Center>
);
}
// Main content
return (
<SaaSCheckoutProvider>
<div>
{/* Header with title and Manage Billing button */}
<Flex justify="space-between" align="center" mb="md">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('settings.planBilling.currentPlan', 'Active Plan')}
</h3>
{tier !== 'free' && !isManagedTeamMember && (
<Button
variant="light"
size="sm"
onClick={handleManageBilling}
loading={isOpeningPortal}
disabled={isOpeningPortal}
>
{t('settings.planBilling.billing.manageBilling', 'Manage Billing')}
</Button>
)}
</Flex>
{/* Trial Status Alert */}
{isTrialing && trialDaysRemaining !== undefined && subscription?.currentPeriodEnd && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('settings.planBilling.trial.title', 'Free Trial Active')}
>
<Text size="sm">
{t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', {
days: trialDaysRemaining,
defaultValue: `Your trial ends in ${trialDaysRemaining} days`,
})}
</Text>
<Text size="xs" c="dimmed">
{t('settings.planBilling.trial.endDate', 'Expires: {{date}}', {
date: formatDate(subscription.currentPeriodEnd),
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
})}
</Text>
</Alert>
)}
{/* Plan cards */}
<Stack gap="lg">
{/* Current subscription card */}
<ActiveSubscriptionCard
tier={tier}
subscription={subscription}
usage={usage}
isTrialing={isTrialing}
price={price}
currency={currency}
currentTeam={currentTeam}
isTeamLeader={isTeamLeader}
isPersonalTeam={isPersonalTeam}
/>
{/* Available plans grid */}
<SaaSAvailablePlansSection
plans={plans}
currentTier={tier}
loading={plansLoading}
error={plansError}
/>
</Stack>
</div>
</SaaSCheckoutProvider>
);
}
@@ -0,0 +1,220 @@
import { Card, Text, Group, Badge, Stack, Tooltip, ActionIcon } from '@mantine/core';
import GroupIcon from '@mui/icons-material/Group';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface TeamData {
teamId: number;
name: string;
isPersonal: boolean;
isLeader: boolean;
seatsUsed: number;
}
interface ActiveSubscriptionCardProps {
tier: BillingStatus['tier'];
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
isTrialing: boolean;
price?: number;
currency?: string;
currentTeam?: TeamData | null;
isTeamLeader?: boolean;
isPersonalTeam?: boolean;
}
export function ActiveSubscriptionCard({
tier,
subscription,
usage,
isTrialing,
price,
currency,
currentTeam,
isTeamLeader = false,
isPersonalTeam = true,
}: ActiveSubscriptionCardProps) {
const { t } = useTranslation();
// Format timestamp to readable date
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Get tier display name
const getTierName = (): string => {
switch (tier) {
case 'free':
return t('settings.planBilling.tier.free', 'Free Plan');
case 'team':
return t('settings.planBilling.tier.team', 'Team Plan');
case 'enterprise':
return t('settings.planBilling.tier.enterprise', 'Enterprise Plan');
default:
return tier;
}
};
// Get price display
const getPriceDisplay = (): string => {
if (tier === 'free') {
return '$0/month';
}
// Use actual price from Stripe if available
if (price !== undefined && currency) {
return `${currency}${price}/month`;
}
// Fallback to default pricing
return '$10/month';
};
// Get description
const getDescription = (): string => {
if (tier === 'free') {
return t('settings.planBilling.tier.freeDescription', '50 credits per month');
}
return t(
'settings.planBilling.tier.teamDescription',
'500 credits/month included, automatic overage billing for uninterrupted service'
);
};
// Format overage cost
const formatOverageCost = (cents: number, credits: number): string => {
return t('settings.planBilling.billing.overageCost', {
amount: `$${(cents / 100).toFixed(2)}`,
credits,
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
});
};
// Pro/Team card
if (tier === 'team' || tier === 'enterprise') {
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
{/* Left side: Name, badges, description */}
<div style={{ flex: 1 }}>
<Group gap="xs" mb="xs">
<Text size="lg" fw={600}>
{!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()}
</Text>
{!isPersonalTeam && (
<Badge color="violet" variant="light" leftSection={<GroupIcon sx={{ fontSize: 12 }} />}>
{t('settings.planBilling.tier.teamBadge', 'Team')}
</Badge>
)}
<Tooltip
label={
<div style={{ maxWidth: 300 }}>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipCredits', {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
})}
</Text>
<Text size="sm" mb="xs">
{t('settings.planBilling.tier.teamTooltipOverage', {
price: getFormattedOveragePrice(),
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
})}
</Text>
<Text size="sm">
{t('settings.planBilling.tier.teamTooltipFineprint', 'Only pay for what you use beyond included credits.')}
</Text>
</div>
}
multiline
withArrow
position="right"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<InfoOutlinedIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
{isTrialing && (
<Badge color="blue" variant="light">
{t('settings.planBilling.status.trial', 'Trial')}
</Badge>
)}
</Group>
{!isPersonalTeam && !isTeamLeader && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
</Text>
)}
{!isPersonalTeam && isTeamLeader && currentTeam && (
<Text size="sm" c="dimmed" mb="xs">
{t('settings.planBilling.team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })}
</Text>
)}
<Text size="sm" c="dimmed" mb="xs">
{getDescription()}
</Text>
{/* Show overage cost if applicable */}
{usage && usage.currentPeriodCredits > 0 && (
<Text size="sm" c="orange" fw={500}>
{formatOverageCost(usage.estimatedCost, usage.currentPeriodCredits)}
</Text>
)}
</div>
{/* Right side: Price */}
<div style={{ textAlign: 'right' }}>
{!isPersonalTeam && !isTeamLeader ? (
<Text size="lg" c="dimmed">
{t('settings.planBilling.team.managedByTeam', 'Managed by team')}
</Text>
) : (
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
)}
</div>
</Group>
{/* Next billing date at bottom */}
{subscription?.currentPeriodEnd && (
<Group gap="xs" mt="xs">
<Text size="sm" c="dimmed">
{t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)}
</Text>
</Group>
)}
</Stack>
</Card>
);
}
// Free plan card
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{getTierName()}
</Text>
</Group>
<Text size="sm" c="dimmed">
{getDescription()}
</Text>
</div>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{getPriceDisplay()}
</Text>
</div>
</Group>
</Card>
);
}
@@ -0,0 +1,79 @@
import React from 'react';
import { Card, Text, Button, Stack, List, ThemeIcon } from '@mantine/core';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useTranslation } from 'react-i18next';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { BILLING_CONFIG } from '@app/config/billing';
import type { TierLevel } from '@app/types/billing';
interface PlanUpgradeCardProps {
currentTier: TierLevel;
}
export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
const { t } = useTranslation();
// Don't show upgrade card if already on Team or Enterprise
if (currentTier !== 'free') {
return null;
}
const handleUpgrade = async () => {
// For MVP, direct to web SaaS for upgrades
const upgradeUrl = `${STIRLING_SAAS_URL}/account?tab=plan`;
try {
await shellOpen(upgradeUrl);
} catch (error) {
console.error('[PlanUpgradeCard] Failed to open upgrade URL:', error);
}
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')}
</Text>
{/* Team plan benefits */}
<Text size="sm" c="dimmed">
{t('settings.planBilling.upgrade.subtitle', 'Upgrade to Team for:')}
</Text>
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon color="blue" size={20} radius="xl">
<CheckCircleIcon sx={{ fontSize: 12 }} />
</ThemeIcon>
}
>
<List.Item>
{t('settings.planBilling.upgrade.featureCredits', {
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`,
})}
</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureMembers', 'Unlimited team members')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureThroughput', 'Faster processing throughput')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureApi', 'API access for automation')}</List.Item>
<List.Item>{t('settings.planBilling.upgrade.featureSupport', 'Priority support')}</List.Item>
</List>
{/* Upgrade button */}
<Button variant="filled" fullWidth onClick={handleUpgrade}>
{t('settings.planBilling.upgrade.cta', 'Upgrade to Team')}
</Button>
<Text size="xs" c="dimmed" ta="center">
{t('settings.planBilling.upgrade.opensInBrowser', 'Opens in browser to complete upgrade')}
</Text>
</Stack>
</Card>
);
}
@@ -0,0 +1,76 @@
import React from 'react';
import { Text, SimpleGrid, Loader, Alert, Center } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { SaasPlanCard } from '@app/components/shared/config/configSections/plan/SaasPlanCard';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import type { TierLevel } from '@app/types/billing';
interface SaaSAvailablePlansSectionProps {
plans: PlanTier[];
currentTier?: TierLevel;
loading?: boolean;
error?: string | null;
}
export const SaaSAvailablePlansSection: React.FC<SaaSAvailablePlansSectionProps> = ({
plans,
currentTier,
loading,
error
}) => {
const { t } = useTranslation();
const { openCheckout } = useSaaSCheckout();
const handleUpgradeClick = (plan: PlanTier) => {
if (plan.isContactOnly) {
// Handled by mailto link in the card
return;
}
if (plan.id === currentTier) {
// Already on this plan
return;
}
console.log('[SaaSAvailablePlansSection] Upgrade clicked for plan:', plan.id);
openCheckout(plan.id);
};
if (loading) {
return (
<Center py="xl">
<Loader size="md" />
</Center>
);
}
if (error) {
return (
<Alert color="orange" variant="light" mt="md">
<Text size="sm">
{t('plan.availablePlans.loadError', 'Unable to load plan pricing. Using default values.')}
</Text>
</Alert>
);
}
return (
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.availablePlans.title', 'Available Plans')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
{plans.map(plan => (
<SaasPlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentTier}
currentTier={currentTier}
onUpgradeClick={handleUpgradeClick}
/>
))}
</SimpleGrid>
</div>
);
};
@@ -0,0 +1,195 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/useSaaSPlans';
import { FeatureListItem } from '@app/components/shared/modals/FeatureListItem';
import type { TierLevel } from '@app/types/billing';
interface SaasPlanCardProps {
plan: PlanTier;
isCurrentPlan?: boolean;
currentTier?: TierLevel;
onUpgradeClick?: (plan: PlanTier) => void;
}
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
plan,
isCurrentPlan,
currentTier,
onUpgradeClick
}) => {
const { t } = useTranslation();
// Free plan is included if user has Team or Enterprise tier
const isIncluded = plan.id === 'free' && (currentTier === 'team' || currentTier === 'enterprise');
// Determine card styling based on plan type
const getCardStyle = () => {
const baseStyle: React.CSSProperties = {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
borderWidth: 1,
position: 'relative',
overflow: 'visible',
};
if (plan.id === 'free' && isCurrentPlan) {
return {
...baseStyle,
borderColor: 'var(--border-default)',
opacity: 0.85,
};
}
if (plan.popular) {
return {
...baseStyle,
borderColor: 'rgb(59, 130, 246)',
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
};
}
return baseStyle;
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
}
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
if (plan.popular && !isCurrentPlan) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.1)';
}
};
const handleClick = () => {
if (plan.popular && !isCurrentPlan && onUpgradeClick) {
onUpgradeClick(plan);
}
};
return (
<Card
key={plan.id}
padding="md"
radius="md"
withBorder
style={getCardStyle()}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{plan.popular && (
<Badge
size="sm"
style={{
position: 'absolute',
top: -10,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgb(59, 130, 246)',
color: 'white',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
paddingLeft: '12px',
paddingRight: '12px',
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="sm" className="h-full">
<div>
<Text size="md" fw={600} mb="xs">{plan.name}</Text>
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" fw={400} c="dimmed">
{plan.isContactOnly
? t('plan.enterprise.siteLicense', 'Site License')
: plan.id === 'free'
? `50 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
: plan.overagePrice
? `500 ${t('credits.modal.monthlyCredits', 'monthly credits')} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t('credits.modal.overage', 'overage')}`
: `500 ${t('credits.modal.monthlyCredits', 'monthly credits')}`
}
</Text>
</div>
<Stack gap="xs">
<Text size="xs" fw={500} mb="xs">
{plan.id === 'free'
? t('credits.modal.forRegularWork', 'For regular PDF work:')
: plan.id === 'enterprise'
? t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')
: t('credits.modal.everythingInFree', 'Everything in Free, plus:')
}
</Text>
{plan.highlights.map((highlight: string, index: number) => (
<FeatureListItem
key={index}
included
color={plan.id === 'free' ? 'var(--mantine-color-gray-6)' : 'var(--color-primary-600)'}
size="xs"
>
{highlight}
</FeatureListItem>
))}
</Stack>
<div className="flex-grow" />
<Button
variant={isCurrentPlan || isIncluded ? "subtle" : plan.isContactOnly ? "outline" : "filled"}
color={plan.isContactOnly ? undefined : "blue"}
disabled={isCurrentPlan || isIncluded}
fullWidth
size="sm"
radius="lg"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick?.(plan);
}}
style={{
fontWeight: 600,
...((isCurrentPlan || isIncluded) && {
background: 'transparent',
border: 'none',
cursor: 'default',
}),
...(plan.isContactOnly && {
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
}),
}}
component={plan.isContactOnly ? 'a' : undefined}
href={plan.isContactOnly ? `mailto:[email protected]?subject=${plan.name} Plan Inquiry` : undefined}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
: isIncluded
? t('plan.included', 'Included')
: plan.isContactOnly
? t('plan.contact', 'Contact Sales')
: t('plan.upgrade', 'Upgrade')
}
</Button>
</Stack>
</Card>
);
};
@@ -0,0 +1,123 @@
import React from 'react';
import { Card, Text, Stack, Group, Progress, Alert } from '@mantine/core';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { useTranslation } from 'react-i18next';
import type { BillingStatus } from '@app/services/saasBillingService';
import { BILLING_CONFIG, getFormattedOveragePrice } from '@app/config/billing';
interface UsageDisplayProps {
tier: BillingStatus['tier'];
usage: BillingStatus['meterUsage'];
}
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
const { t } = useTranslation();
// Credits per month based on tier
const getMonthlyCredits = (): number => {
switch (tier) {
case 'free':
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
case 'team':
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
case 'enterprise':
return 1000; // Placeholder — enterprise credits are custom
default:
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
}
};
const monthlyCredits = getMonthlyCredits();
// Format currency
const formatCurrency = (cents: number): string => {
return `$${(cents / 100).toFixed(2)}`;
};
return (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
{/* Header */}
<Text size="lg" fw={600}>
{t('settings.planBilling.credits.title', 'Credit Usage')}
</Text>
{/* Monthly credits info */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.included', {
count: monthlyCredits,
defaultValue: `${monthlyCredits} credits/month (included)`,
})}
</Text>
</Group>
{/* Overage credits (if metered billing enabled) */}
{usage && usage.currentPeriodCredits > 0 && (
<>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('settings.planBilling.credits.overage', {
count: usage.currentPeriodCredits,
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
})}
</Text>
<Text size="sm" fw={500} c="orange">
{t('settings.planBilling.credits.estimatedCost', {
amount: formatCurrency(usage.estimatedCost),
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
})}
</Text>
</Group>
{/* Progress bar for overage usage */}
<Progress
value={100}
color="orange"
size="sm"
radius="xl"
striped
animated
/>
</Stack>
<Alert color="blue" variant="light" icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}>
<Text size="xs">
{t('settings.planBilling.credits.overageInfo', {
price: getFormattedOveragePrice(),
defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`,
})}
</Text>
</Alert>
</>
)}
{/* No overage message */}
{(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && (
<Alert color="green" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.noOverage', {
count: monthlyCredits,
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
})}
</Text>
</Alert>
)}
{/* Free tier message */}
{tier === 'free' && (
<Alert color="blue" variant="light">
<Text size="sm">
{t('settings.planBilling.credits.freeTierInfo', {
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`,
})}
</Text>
</Alert>
)}
</Stack>
</Card>
);
}
@@ -3,6 +3,7 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/
export const VALID_NAV_KEYS = [
...CORE_NAV_KEYS,
'connectionMode',
'planBilling',
] as const;
export type NavKey = typeof VALID_NAV_KEYS[number];
@@ -0,0 +1,485 @@
import { Modal, Stack, Card, Text, Group, Badge, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { BILLING_CONFIG, getCurrencySymbol, getFormattedOveragePrice } from '@app/config/billing';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { CreditUsageBanner } from '@app/components/shared/modals/CreditUsageBanner';
import { FeatureListItem } from '@app/components/shared/modals/FeatureListItem';
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import { useEnableMeteredBilling } from '@app/hooks/useEnableMeteredBilling';
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
/**
* Desktop Credit Exhausted Modal
* Shows upgrade options when user runs out of credits
* Routes to different UI based on user status (free/team/managed member)
*/
export function CreditExhaustedModal({ opened, onClose }: CreditExhaustedModalProps) {
const { t } = useTranslation();
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } = useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
'CreditExhaustedModal'
);
// Managed team members have unlimited credits via team
if (isManagedTeamMember) {
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={t('credits.modal.managedMemberTitle', 'Unlimited Credits')}
>
<Stack gap="md">
<Text size="sm">
{t('credits.modal.managedMemberMessage', 'You have unlimited access to credits through your team. If you need assistance, please contact your team leader.')}
</Text>
<Button onClick={onClose} fullWidth>
{t('common.close', 'Close')}
</Button>
</Stack>
</Modal>
);
}
// Team users should enable overage billing
// Only team leaders can enable metered billing, members see different UI
if (tier === 'team') {
const teamPlan = plans.get('team');
const teamCurrency = teamPlan?.currency ?? '$';
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const formattedOveragePrice = getFormattedOveragePrice(teamCurrency, overagePrice);
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside={!enablingMetering}
closeOnEscape={!enablingMetering}
centered
size="lg"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t('credits.modal.titleExhaustedPro', 'You have run out of credits')}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.subtitlePro', 'Enable automatic overage billing to never run out of credits.')}
</Text>
</Stack>
}
styles={{
body: { padding: '0rem 0rem 0.5rem 0rem' },
content: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
},
header: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
},
overlay: {
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
/>
{meteringError && (
<Alert color="red" ml="lg" mr="lg">
{meteringError}
</Alert>
)}
{/* Explanation Card */}
<Card
padding="xl"
radius="md"
withBorder
ml="lg"
mr="lg"
style={{
borderColor: 'var(--color-primary-600)',
borderWidth: 2,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
}}
>
<Stack gap="md">
<Group gap="xs" align="center">
<TrendingUpIcon sx={{ fontSize: 24, color: 'var(--color-primary-600)' }} />
<Text size="lg" fw={600}>
{t('credits.modal.meteringTitle', 'Pay-What-You-Use Overage Billing')}
</Text>
</Group>
<Text size="sm" c="dimmed">
{t('credits.modal.meteringExplanation', {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`,
})}
</Text>
<Stack gap="xs">
<FeatureListItem included>
{t('credits.modal.meteringIncluded', {
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
})}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringPrice', 'Additional credits at {{price}}/credit', {
price: formattedOveragePrice,
})}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringPayAsYouGo', 'Only pay for what you use')}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringNoCommitment', 'No commitment, cancel anytime')}
</FeatureListItem>
<FeatureListItem included>
{t('credits.modal.meteringNeverRunOut', 'Never run out of credits')}
</FeatureListItem>
</Stack>
<Card
padding="md"
radius="md"
style={{
backgroundColor: 'light-dark(#F8F9FA, #1A1A1E)',
border: '1px solid var(--border-subtle)',
}}
>
<Text size="xs" c="dimmed">
{t('credits.modal.meteringBillingNote', 'Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.')}
</Text>
</Card>
</Stack>
</Card>
{/* Action Buttons */}
<Stack gap="sm" ml="lg" mr="lg">
<Button
onClick={handleEnableMetering}
variant="filled"
color="blue"
fullWidth
size="lg"
loading={enablingMetering}
disabled={!isTeamLeader}
leftSection={<TrendingUpIcon sx={{ fontSize: 18 }} />}
style={{
fontWeight: 600,
}}
>
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')}
</Text>
)}
<Button onClick={onClose} variant="subtle" fullWidth size="md" c="dimmed" disabled={enablingMetering}>
{t('credits.maybeLater', 'Maybe later')}
</Button>
</Stack>
</Stack>
</Modal>
);
}
// Free tier users - show upgrade modal
const teamPlan = plans.get('team');
const teamPrice = teamPlan?.price ?? 20;
const teamCurrency = teamPlan?.currency ?? '$';
const overagePrice = teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
const currencySymbol = getCurrencySymbol(teamCurrency);
const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`;
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside
closeOnEscape
centered
size="60rem"
radius="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Stack gap="sm">
<Text size="lg" fw={450}>
{t('credits.modal.titleExhausted', "You've used your free credits")}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.subtitle', 'Upgrade to Team for 10x the credits and faster processing.')}
</Text>
</Stack>
}
styles={{
body: { padding: '0rem 0rem 0.5rem 0rem' },
content: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
},
header: {
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
},
overlay: {
backgroundColor: 'light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))',
},
}}
>
<Stack gap="lg">
<CreditUsageBanner
currentCredits={creditBalance}
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
/>
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
{/* Free Plan Card */}
<Card
padding="xl"
radius="md"
withBorder
style={{
borderColor: 'var(--border-default)',
borderWidth: 1,
opacity: 0.85,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
}}
>
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t('credits.modal.freeTier', 'Free Tier')}
</Text>
<Group gap="xs" align="baseline">
<Text size="1.75rem" fw={700}>
{currencySymbol}0
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.perMonth', '/month')}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.forRegularWork', 'For regular PDF work:')}
</Text>
{FREE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included color="var(--mantine-color-gray-6)">
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
disabled
variant="subtle"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
background: 'transparent',
border: 'none',
cursor: 'default',
}}
>
{t('credits.modal.current', 'Current Plan')}
</Button>
</Stack>
</Card>
{/* Team Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderColor: 'var(--card-selected-border)',
borderWidth: 2,
cursor: 'pointer',
transition: 'all 0.2s ease',
position: 'relative',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.1)',
overflow: 'visible',
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
}}
onClick={() => openCheckout('pro')}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 12px 48px rgba(59, 130, 246, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.1)';
}}
>
<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('credits.modal.popular', 'Popular')}
</Badge>
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600} mb="xs">
{t('credits.modal.teamSubscription', 'Team')}
</Text>
<Group gap="xs" align="baseline" mt="xs">
<Text size="1.75rem" fw={700} style={{ color: 'var(--text-primary)' }}>
{currencySymbol}
{teamPrice}
</Text>
<Text size="sm" c="dimmed">
{t('credits.modal.perMonth', '/month')}
</Text>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')} + {formattedOveragePrice}/{t('credits.modal.overage', 'overage')}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.everythingInFree', 'Everything in Free, plus:')}
</Text>
{TEAM_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
onClick={() => openCheckout('pro')}
variant="filled"
color="blue"
fullWidth
size="md"
radius="lg"
style={{
fontWeight: 600,
}}
>
{t('credits.upgrade', 'Upgrade')}
</Button>
</Stack>
</Card>
{/* Enterprise Plan Card */}
<Card
padding="lg"
radius="md"
withBorder
style={{
borderWidth: 1,
backgroundColor: 'light-dark(#FFFFFF, #1A1A1E)',
}}
>
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="md" fw={600} mb="xs">
{t('credits.modal.enterpriseSubscription', 'Enterprise')}
</Text>
<Text size="1.75rem" fw={600}>
{t('credits.modal.customPricing', 'Custom')}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('credits.modal.unlimitedMonthlyCredits', 'Site License')}
</Text>
</div>
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="sm" fw={500} mb="xs">
{t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')}
</Text>
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
<FeatureListItem key={index} included>
{t(feature.translationKey, feature.defaultText)}
</FeatureListItem>
))}
</Stack>
<Button
component="a"
href="mailto:[email protected]?subject=Enterprise Plan Inquiry"
variant="outline"
fullWidth
size="md"
radius="lg"
style={{
borderColor: 'var(--text-primary)',
color: 'var(--text-primary)',
fontWeight: 600,
}}
>
{t('credits.modal.contactSales', 'Contact Sales')}
</Button>
</Stack>
</Card>
</Group>
<Text size="sm" ta="center" c="dimmed" mt="md">
{t('credits.modal.selfHostPrompt', 'Want to self host?')}{' '}
<Text
component="a"
href="https://www.stirling.com/pricing"
target="_blank"
rel="noopener noreferrer"
size="sm"
style={{ color: 'var(--mantine-color-blue-6)', textDecoration: 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.textDecoration = 'underline';
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecoration = 'none';
}}
>
{t('credits.modal.selfHostLink', 'Review the docs and plans')}
</Text>
</Text>
</Stack>
</Modal>
);
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { CreditExhaustedModal } from '@app/components/shared/modals/CreditExhaustedModal';
import { InsufficientCreditsModal } from '@app/components/shared/modals/InsufficientCreditsModal';
import { useCreditEvents } from '@app/hooks/useCreditEvents';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
/**
* Desktop Credit Modal Bootstrap
* Listens to credit events and shows appropriate modals
* Orchestrates credit exhausted and insufficient credits modals
*/
export function CreditModalBootstrap() {
const [exhaustedOpen, setExhaustedOpen] = useState(false);
const [insufficientOpen, setInsufficientOpen] = useState(false);
const [insufficientDetails, setInsufficientDetails] = useState<{
toolId?: string;
requiredCredits?: number;
}>({});
const { creditBalance, isManagedTeamMember } = useSaaSBilling();
// Monitor credit balance and dispatch events
useCreditEvents();
useEffect(() => {
const handleExhausted = () => {
// Don't show modal for managed team members
if (isManagedTeamMember) {
return;
}
setExhaustedOpen(true);
};
const handleInsufficient = (e: Event) => {
// Don't show modal for managed team members
if (isManagedTeamMember) {
return;
}
const customEvent = e as CustomEvent;
setInsufficientDetails({
toolId: customEvent.detail?.operationType,
requiredCredits: customEvent.detail?.requiredCredits,
});
setInsufficientOpen(true);
};
window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
window.addEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient);
return () => {
window.removeEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
window.removeEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient);
};
}, [isManagedTeamMember, creditBalance]);
return (
<>
<CreditExhaustedModal
opened={exhaustedOpen && !insufficientOpen}
onClose={() => setExhaustedOpen(false)}
/>
<InsufficientCreditsModal
opened={insufficientOpen}
onClose={() => setInsufficientOpen(false)}
toolId={insufficientDetails.toolId}
requiredCredits={insufficientDetails.requiredCredits}
/>
</>
);
}
@@ -0,0 +1,45 @@
import { Divider, Group, Text, Progress, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface CreditUsageBannerProps {
currentCredits: number;
totalCredits: number;
}
/**
* Credit usage banner showing remaining credits with progress bar
* Used in credit exhausted and upgrade modals
*/
export function CreditUsageBanner({ currentCredits, totalCredits }: CreditUsageBannerProps) {
const { t } = useTranslation();
const percentageRemaining = totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0;
return (
<Stack gap="md">
<Divider />
<Stack gap="xs" pr="md" pl="md">
<Group gap="xs" justify="space-between" align="center">
<Text size="md" fw={400} c="dimmed">
{t('credits.modal.creditsThisMonth', 'Monthly credits')}
</Text>
<Text size="md" fw={600} style={{ color: 'var(--text-primary)' }}>
{t('credits.modal.creditsRemaining', '{{current}} of {{total}} remaining', {
current: currentCredits,
total: totalCredits,
})}
</Text>
</Group>
<Progress
value={percentageRemaining}
size="sm"
radius="xl"
color="blue"
styles={{
root: { backgroundColor: 'var(--bg-raised)' },
}}
/>
</Stack>
<Divider />
</Stack>
);
}
@@ -0,0 +1,49 @@
import { Group, Text } from '@mantine/core';
import CheckCircleIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
interface FeatureListItemProps {
children: React.ReactNode;
included: boolean;
color?: string;
dimmed?: boolean;
fw?: number;
size?: 'xs' | 'sm' | 'md' | 'lg' | string;
}
export function FeatureListItem({
children,
included,
color = 'var(--color-primary-600)',
dimmed = false,
fw = 400,
size = 'sm'
}: FeatureListItemProps) {
const Icon = included ? CheckCircleIcon : CloseIcon;
const iconColor = included ? color : 'var(--color-red-600)';
// Map Mantine sizes to icon font sizes
const iconSizeMap: Record<string, number> = {
xs: 14,
sm: 16,
md: 18,
lg: 20
};
// Determine icon size - use mapped value if it exists, otherwise use the string directly
const iconSize = iconSizeMap[size] || size;
// For Text component, only use Mantine sizes if the size is a predefined key
const textSize = iconSizeMap[size] ? size : undefined;
return (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Icon
sx={{ fontSize: iconSize, color: iconColor, flexShrink: 0, marginTop: '2px' }}
/>
<Text size={textSize} c={dimmed ? 'dimmed' : undefined} fw={fw} style={textSize ? undefined : { fontSize: size }}>
{children}
</Text>
</Group>
);
}
@@ -0,0 +1,152 @@
import { Modal, Stack, Text, Button, Alert, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useSaaSCheckout } from '@app/contexts/SaaSCheckoutContext';
import WarningIcon from '@mui/icons-material/Warning';
import { useEnableMeteredBilling } from '@app/hooks/useEnableMeteredBilling';
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
/**
* Desktop Insufficient Credits Modal
* Shows when user attempts operation without enough credits
*/
export function InsufficientCreditsModal({
opened,
onClose,
toolId,
requiredCredits,
}: InsufficientCreditsModalProps) {
const { t } = useTranslation();
const { creditBalance, tier, refreshBilling, isManagedTeamMember } = useSaaSBilling();
const { isTeamLeader } = useSaaSTeam();
const { openCheckout } = useSaaSCheckout();
const { enablingMetering, meteringError, handleEnableMetering } = useEnableMeteredBilling(
refreshBilling,
onClose,
'InsufficientCreditsModal'
);
const toolName = toolId ? t(`tool.${toolId}.name`, toolId) : t('common.operation', 'this operation');
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
title={
<Group gap="xs">
<WarningIcon sx={{ fontSize: 24, color: 'var(--mantine-color-orange-6)' }} />
<Text size="lg" fw={500}>
{t('credits.insufficient.title', 'Insufficient Credits')}
</Text>
</Group>
}
>
<Stack gap="md">
<Alert color="orange" icon={<WarningIcon />}>
<Text size="sm">
{requiredCredits
? t(
'credits.insufficient.messageWithAmount',
'You need {{required}} credits to run {{tool}}, but you only have {{current}}.',
{
required: requiredCredits,
tool: toolName,
current: creditBalance,
}
)
: t(
'credits.insufficient.message',
'You do not have enough credits to run {{tool}}. You currently have {{current}} credits.',
{
tool: toolName,
current: creditBalance,
}
)}
</Text>
</Alert>
{isManagedTeamMember ? (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.managedMember',
'Please contact your team leader for assistance.'
)}
</Text>
<Button onClick={onClose} fullWidth>
{t('common.close', 'Close')}
</Button>
</>
) : tier === 'team' ? (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.teamMember',
'Enable overage billing to never run out of credits.'
)}
</Text>
{meteringError && (
<Alert color="red">
{meteringError}
</Alert>
)}
<Button
variant="filled"
color="blue"
fullWidth
onClick={handleEnableMetering}
loading={enablingMetering}
disabled={!isTeamLeader}
>
{t('credits.enableOverageBilling', 'Enable Overage Billing')}
</Button>
{!isTeamLeader && (
<Text size="xs" c="dimmed" ta="center">
{t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')}
</Text>
)}
<Button onClick={onClose} variant="subtle" fullWidth disabled={enablingMetering}>
{t('common.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<Text size="sm" c="dimmed">
{t(
'credits.insufficient.freeTier',
'Upgrade to Team for 10x more credits and unlimited overage billing.'
)}
</Text>
<Button
variant="filled"
color="blue"
fullWidth
onClick={() => {
openCheckout('pro');
onClose();
}}
>
{t('credits.upgrade', 'Upgrade to Team')}
</Button>
<Button onClick={onClose} variant="subtle" fullWidth>
{t('common.cancel', 'Cancel')}
</Button>
</>
)}
</Stack>
</Modal>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Billing Configuration for Desktop
* Single source of truth for billing-related constants
*/
export const BILLING_CONFIG = {
// Credits included in Free plan (per month)
FREE_CREDITS_PER_MONTH: 50,
// Credits included in Pro plan (per month)
INCLUDED_CREDITS_PER_MONTH: 500,
// Overage pricing (per credit) - also fetched dynamically from Stripe
OVERAGE_PRICE_PER_CREDIT: 0.05,
// Credit warning threshold
LOW_CREDIT_THRESHOLD: 10,
// Stripe lookup keys
PRO_PLAN_LOOKUP_KEY: 'plan:pro',
METER_LOOKUP_KEY: 'meter:overage',
// Display formats
CURRENCY_SYMBOLS: {
gbp: '£',
usd: '$',
eur: '€',
cny: '¥',
inr: '₹',
brl: 'R$',
idr: 'Rp',
jpy: '¥'
} as const
} as const;
/**
* Get current billing configuration
*/
export function getBillingConfig() {
return BILLING_CONFIG;
}
/**
* Format overage price with currency symbol
* @param currency Currency code (e.g., 'usd', 'gbp')
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
*/
export function getFormattedOveragePrice(currency: string = 'usd', price?: number): string {
const symbol = BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || '$';
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
return `${symbol}${amount.toFixed(2)}`;
}
/**
* Get currency symbol from currency code
*/
export function getCurrencySymbol(currency: string): string {
return BILLING_CONFIG.CURRENCY_SYMBOLS[currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS] || currency.toUpperCase();
}
@@ -0,0 +1,86 @@
/**
* Desktop plan features configuration
* Single source of truth for plan features in desktop billing page
*/
export interface PlanFeatureConfig {
translationKey: string;
defaultText: string;
}
export const FREE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.allInOneWorkspace',
defaultText: 'All-in-one PDF workspace (viewer, tools & agent)'
},
{
translationKey: 'credits.modal.fullyPrivateFiles',
defaultText: 'Fully private files'
},
{
translationKey: 'credits.modal.standardThroughput',
defaultText: 'Standard throughput'
},
{
translationKey: 'credits.modal.customSmartFolders',
defaultText: 'Custom Smart Folders'
},
{
translationKey: 'credits.modal.apiSandbox',
defaultText: 'API sandbox'
}
];
export const TEAM_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.unlimitedSeats',
defaultText: 'Unlimited seats'
},
{
translationKey: 'credits.modal.fasterThroughput',
defaultText: '10x faster throughput'
},
{
translationKey: 'credits.modal.largeFileProcessing',
defaultText: 'Large file processing'
},
{
translationKey: 'credits.modal.premiumAiModels',
defaultText: 'Premium AI models'
},
{
translationKey: 'credits.modal.secureApiAccess',
defaultText: 'Secure API access'
},
{
translationKey: 'credits.modal.prioritySupport',
defaultText: 'Priority support'
}
];
export const ENTERPRISE_PLAN_FEATURES: PlanFeatureConfig[] = [
{
translationKey: 'credits.modal.orgWideAccess',
defaultText: 'Org-wide access controls'
},
{
translationKey: 'credits.modal.privateDocCloud',
defaultText: 'Private Document Cloud'
},
{
translationKey: 'credits.modal.ragFineTuning',
defaultText: 'RAG + fine-tuning'
},
{
translationKey: 'credits.modal.unlimitedApiAccess',
defaultText: 'Unlimited API access'
},
{
translationKey: 'credits.modal.advancedMonitoring',
defaultText: 'Advanced monitoring'
},
{
translationKey: 'credits.modal.dedicatedSupportSlas',
defaultText: 'Dedicated support & SLAs'
}
];
+10 -1
View File
@@ -3,12 +3,21 @@
*/
// SaaS server URL from environment variable
// The SaaS authentication server
// The SaaS authentication server (Supabase)
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
// SaaS backend API URL from environment variable
// The Stirling SaaS backend API server (for team endpoints, etc.)
export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_BACKEND_API_URL || '';
// Supabase publishable key from environment variable
// Used for SaaS authentication
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
// Desktop deep link callback for Supabase email confirmations
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
// Validation warnings
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.warn('[Desktop Connection] VITE_SAAS_BACKEND_API_URL not configured - SaaS backend APIs (teams, etc.) will not work');
}
@@ -0,0 +1,12 @@
/**
* Credit event constants for desktop credit system
* Used for communication between credit monitoring, UI, and operations
*/
export const CREDIT_EVENTS = {
EXHAUSTED: 'credits:exhausted',
INSUFFICIENT: 'credits:insufficient',
REFRESH_NEEDED: 'credits:refresh-needed',
} as const;
export type CreditEventType = typeof CREDIT_EVENTS[keyof typeof CREDIT_EVENTS];
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { SaaSStripeCheckout } from '@app/components/shared/billing/SaaSStripeCheckout';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
interface SaaSCheckoutContextType {
opened: boolean;
selectedPlan: string | null;
openCheckout: (planId: string) => void;
closeCheckout: () => void;
}
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(undefined);
export const useSaaSCheckout = () => {
const context = useContext(SaaSCheckoutContext);
if (!context) {
throw new Error('useSaaSCheckout must be used within SaaSCheckoutProvider');
}
return context;
};
interface SaaSCheckoutProviderProps {
children: ReactNode;
}
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
children
}) => {
const [opened, setOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
// Access billing context for auto-refresh after checkout
const { refreshBilling } = useSaaSBilling();
const openCheckout = (planId: string) => {
setSelectedPlan(planId);
setOpened(true);
};
const closeCheckout = () => {
setOpened(false);
// Don't reset selectedPlan immediately to allow for cleanup
setTimeout(() => setSelectedPlan(null), 300);
};
// Internal success handler - automatically refreshes billing context
const handleCheckoutSuccess = useCallback(async () => {
// Wait for webhooks to process (2 seconds)
await new Promise(resolve => setTimeout(resolve, 2000));
try {
await refreshBilling();
} catch (error) {
console.error('[SaaSCheckoutContext] Failed to refresh billing after checkout:', error);
}
}, [refreshBilling]);
return (
<SaaSCheckoutContext.Provider
value={{
opened,
selectedPlan,
openCheckout,
closeCheckout,
}}
>
{children}
<SaaSStripeCheckout
opened={opened}
onClose={closeCheckout}
planId={selectedPlan}
onSuccess={handleCheckoutSuccess}
/>
</SaaSCheckoutContext.Provider>
);
};
@@ -0,0 +1,322 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
/**
* Desktop implementation of SaaS Team Context
* Provides team management for users connected to SaaS backend
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
*/
interface Team {
teamId: number;
name: string;
teamType: string;
isPersonal: boolean;
memberCount: number;
seatCount: number;
seatsUsed: number;
maxSeats: number;
isLeader: boolean;
}
interface TeamMember {
id: number;
username: string;
email: string;
role: string;
joinedAt: string;
}
interface TeamInvitation {
invitationId: number;
teamName: string;
inviterEmail: string;
inviteeEmail: string;
invitationToken: string;
status: string;
expiresAt: string;
}
interface SaaSTeamContextType {
currentTeam: Team | null;
teams: Team[];
teamMembers: TeamMember[];
teamInvitations: TeamInvitation[];
receivedInvitations: TeamInvitation[];
isTeamLeader: boolean;
isPersonalTeam: boolean;
loading: boolean;
inviteUser: (email: string) => Promise<void>;
acceptInvitation: (token: string) => Promise<void>;
rejectInvitation: (token: string) => Promise<void>;
cancelInvitation: (invitationId: number) => Promise<void>;
removeMember: (memberId: number) => Promise<void>;
leaveTeam: () => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextType>({
currentTeam: null,
teams: [],
teamMembers: [],
teamInvitations: [],
receivedInvitations: [],
isTeamLeader: false,
isPersonalTeam: true,
loading: true,
inviteUser: async () => {},
acceptInvitation: async () => {},
rejectInvitation: async () => {},
cancelInvitation: async () => {},
removeMember: async () => {},
leaveTeam: async () => {},
refreshTeams: async () => {},
});
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
const [receivedInvitations, setReceivedInvitations] = useState<TeamInvitation[]>([]);
const [loading, setLoading] = useState(true);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Check if in SaaS mode and authenticated
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;
}, []);
const fetchMyTeams = useCallback(async () => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated');
return null;
}
try {
const response = await apiClient.get<Team[]>('/api/v1/team/my');
setTeams(response.data);
const activeTeam = response.data[0];
console.log('[SaaSTeamContext] Current team set:', {
teamId: activeTeam?.teamId,
name: activeTeam?.name,
isPersonal: activeTeam?.isPersonal,
isLeader: activeTeam?.isLeader,
});
setCurrentTeam(activeTeam || null);
return activeTeam || null;
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch teams:', error);
return null;
}
}, [isSaasMode, isAuthenticated]);
const fetchTeamMembers = useCallback(async (teamId: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated');
return;
}
try {
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`);
setTeamMembers(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
}
}, [isSaasMode, isAuthenticated]);
const fetchTeamInvitations = useCallback(async (teamId?: number) => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated || !teamId) {
return;
}
try {
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`);
setTeamInvitations(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
}
}, [isSaasMode, isAuthenticated]);
const fetchReceivedInvitations = useCallback(async () => {
// CRITICAL: Only fetch if in SaaS mode and authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
console.log('[SaaSTeamContext] Fetching received team invitations');
try {
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending');
console.log('[SaaSTeamContext] Received invitations response:', response.data);
setReceivedInvitations(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch received invitations:', error);
}
}, [isSaasMode, isAuthenticated]);
useEffect(() => {
if (isSaasMode && isAuthenticated) {
fetchMyTeams();
fetchReceivedInvitations();
} else {
// Clear state when not in SaaS mode or not authenticated
setTeams([]);
setCurrentTeam(null);
setTeamMembers([]);
setTeamInvitations([]);
setReceivedInvitations([]);
setLoading(false);
}
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
useEffect(() => {
if (currentTeam && !currentTeam.isPersonal && isSaasMode && isAuthenticated) {
fetchTeamMembers(currentTeam.teamId);
// Only fetch invitations if user is team leader
if (currentTeam.isLeader) {
fetchTeamInvitations(currentTeam.teamId);
} else {
setTeamInvitations([]);
}
} else {
setTeamMembers([]);
setTeamInvitations([]);
}
setLoading(false);
}, [currentTeam, isSaasMode, isAuthenticated, fetchTeamMembers, fetchTeamInvitations]);
const inviteUser = async (email: string) => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.post('/api/v1/team/invite', {
teamId: currentTeam.teamId,
email
});
await fetchTeamInvitations(currentTeam.teamId);
};
const acceptInvitation = async (token: string) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations();
await refreshTeams();
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
};
const rejectInvitation = async (token: string) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
await fetchReceivedInvitations();
};
const cancelInvitation = async (invitationId: number) => {
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
if (currentTeam) {
await fetchTeamInvitations(currentTeam.teamId);
}
};
const removeMember = async (memberId: number) => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.delete(`/api/v1/team/${currentTeam.teamId}/members/${memberId}`);
await refreshTeams();
await fetchTeamMembers(currentTeam.teamId);
};
const leaveTeam = async () => {
if (!currentTeam) throw new Error('No current team');
if (!isSaasMode) throw new Error('Not in SaaS mode');
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams();
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
};
const refreshTeams = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
console.log('[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated');
return;
}
const newCurrentTeam = await fetchMyTeams();
await fetchReceivedInvitations();
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
await fetchTeamMembers(newCurrentTeam.teamId);
// Only fetch invitations if user is team leader
if (newCurrentTeam.isLeader) {
await fetchTeamInvitations(newCurrentTeam.teamId);
}
}
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations, fetchTeamMembers, fetchTeamInvitations]);
const isTeamLeader = currentTeam?.isLeader ?? false;
const isPersonalTeam = currentTeam?.isPersonal ?? true;
return (
<SaaSTeamContext.Provider value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams
}}>
{children}
</SaaSTeamContext.Provider>
);
}
export function useSaaSTeam() {
const context = useContext(SaaSTeamContext);
if (context === undefined) {
throw new Error('useSaaSTeam must be used within a SaaSTeamProvider');
}
return context;
}
export { SaaSTeamContext };
export type { Team, TeamMember, TeamInvitation };
@@ -0,0 +1,315 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import type { TierLevel } from '@app/types/billing';
/**
* Desktop implementation of SaaS Billing Context
* Provides billing and plan management for users connected to SaaS backend
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
*
* Features:
* - Centralized billing state management
* - Automatic caching (5-minute TTL)
* - Lazy loading (fetches on first access)
* - Auto-refresh after checkout/portal
* - No flickering (preserves data during refresh)
*/
interface SaasBillingContextType {
// Billing Status
billingStatus: BillingStatus | null;
tier: TierLevel;
subscription: BillingStatus['subscription'];
usage: BillingStatus['meterUsage'];
isTrialing: boolean;
trialDaysRemaining?: number;
price?: number;
currency?: string;
creditBalance: number; // Real-time remaining credits
// Available Plans
plans: Map<string, PlanPrice>;
plansLoading: boolean;
plansError: string | null;
// Derived State
isManagedTeamMember: boolean;
// State Flags
loading: boolean;
error: string | null;
lastFetchTime: number | null;
// Actions
refreshBilling: () => Promise<void>;
refreshCredits: () => Promise<void>; // Alias for refreshBilling (for clarity)
refreshPlans: () => Promise<void>;
openBillingPortal: () => Promise<void>;
}
const SaasBillingContext = createContext<SaasBillingContextType>({
billingStatus: null,
tier: 'free',
subscription: null,
usage: null,
isTrialing: false,
trialDaysRemaining: undefined,
price: undefined,
currency: undefined,
creditBalance: 0,
plans: new Map(),
plansLoading: false,
plansError: null,
isManagedTeamMember: false,
loading: false,
error: null,
lastFetchTime: null,
refreshBilling: async () => {},
refreshCredits: async () => {},
refreshPlans: async () => {},
openBillingPortal: async () => {},
});
export function SaasBillingProvider({ children }: { children: ReactNode }) {
const [billingStatus, setBillingStatus] = useState<BillingStatus | null>(null);
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
const [plansLoading, setPlansLoading] = useState(false);
const [plansError, setPlansError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Access team context for derived state
const { currentTeam, isPersonalTeam, isTeamLeader, loading: teamLoading } = useSaaSTeam();
// Compute derived state: user is managed member if in non-personal team but not leader
const isManagedTeamMember = currentTeam ? !isPersonalTeam && !isTeamLeader : false;
// Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext)
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;
}, []);
// Fetch billing status with caching
const fetchBillingData = useCallback(async () => {
// Guard: Skip if not in SaaS mode or not authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
// Guard: Wait for team context to load before determining managed status
if (teamLoading) {
return;
}
// Guard: Skip if managed team member (billing managed by leader)
if (isManagedTeamMember) {
return;
}
// Cache check: Skip if fresh data exists (<5 min old)
const now = Date.now();
if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
return;
}
// Only set loading if no existing data (prevents flicker on refresh)
if (!billingStatus) {
setLoading(true);
}
try {
const status = await saasBillingService.getBillingStatus();
setBillingStatus(status); // Atomic update
setLastFetchTime(now);
setError(null);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch billing:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch billing');
// Don't clear billing status on error (preserve existing data)
} finally {
setLoading(false);
}
}, [isSaasMode, isAuthenticated, isManagedTeamMember, billingStatus, lastFetchTime, teamLoading]);
// Fetch available plans
const fetchPlansData = useCallback(async () => {
// Guard: Skip if not in SaaS mode or not authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
setPlansLoading(true);
setPlansError(null);
try {
const priceMap = await saasBillingService.getAvailablePlans('usd');
setPlans(priceMap);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch plans:', err);
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
// Non-blocking: continue with empty plans
} finally {
setPlansLoading(false);
}
}, [isSaasMode, isAuthenticated]);
// Clear data when leaving SaaS mode or logging out
useEffect(() => {
if (!isSaasMode || !isAuthenticated) {
// Clear state when not in SaaS mode or not authenticated
setBillingStatus(null);
setPlans(new Map());
setLastFetchTime(null);
setLoading(false);
setError(null);
setPlansError(null);
}
}, [isSaasMode, isAuthenticated]);
// Auto-fetch billing when team context finishes loading
useEffect(() => {
// Only fetch if: in SaaS mode, authenticated, team finished loading, and haven't fetched yet
if (
isSaasMode &&
isAuthenticated &&
!teamLoading &&
!isManagedTeamMember &&
billingStatus === null &&
lastFetchTime === null
) {
fetchBillingData();
}
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
// Public refresh methods
const refreshBilling = useCallback(async () => {
if (!isSaasMode || !isAuthenticated) {
return;
}
// Force cache invalidation
setLastFetchTime(null);
await fetchBillingData();
await fetchPlansData();
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
const refreshPlans = useCallback(async () => {
await fetchPlansData();
}, [fetchPlansData]);
const openBillingPortal = useCallback(async () => {
const returnUrl = window.location.href;
await saasBillingService.openBillingPortal(returnUrl);
// Auto-refresh after portal (delayed for webhook processing)
setTimeout(() => {
refreshBilling();
}, 3000);
}, [refreshBilling]);
// Listen for credit updates from API response headers (immediate, no fetch)
useEffect(() => {
const handleCreditsUpdated = (e: Event) => {
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
const newBalance = customEvent.detail?.creditsRemaining;
if (typeof newBalance === 'number' && billingStatus) {
// Update credit balance in billing status without full refresh
setBillingStatus({
...billingStatus,
creditBalance: newBalance,
});
// Dispatch exhausted event if credits hit 0
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
window.dispatchEvent(new CustomEvent('credits:exhausted', {
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance }
}));
}
}
};
window.addEventListener('credits:updated', handleCreditsUpdated);
return () => {
window.removeEventListener('credits:updated', handleCreditsUpdated);
};
}, [billingStatus]);
return (
<SaasBillingContext.Provider value={{
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: billingStatus?.price,
currency: billingStatus?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime,
refreshBilling,
refreshCredits: refreshBilling, // Alias for clarity
refreshPlans,
openBillingPortal,
}}>
{children}
</SaasBillingContext.Provider>
);
}
export function useSaaSBilling() {
const context = useContext(SaasBillingContext);
if (context === undefined) {
throw new Error('useSaaSBilling must be used within SaasBillingProvider');
}
// Lazy fetch: Trigger fetch on first access (after team context loads)
// Note: context.loading includes teamLoading, so this waits for team to load
useEffect(() => {
const needsFetch =
context.billingStatus === null &&
context.lastFetchTime === null &&
!context.loading &&
!context.isManagedTeamMember; // Managed members don't need billing data
if (needsFetch) {
context.refreshBilling();
}
}, [context.billingStatus, context.lastFetchTime, context.loading, context.isManagedTeamMember, context.refreshBilling]);
return context;
}
export { SaasBillingContext };
export type { SaasBillingContextType };
@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
import { getEndpointName } from '@app/utils/convertUtils';
/**
* Comprehensive conversion status data
*/
export interface ConversionStatus {
availability: Record<string, boolean>; // Available on local OR SaaS?
cloudStatus: Record<string, boolean>; // Will use cloud?
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
}
/**
* Desktop hook to check conversion availability and cloud routing
* Returns comprehensive data about each conversion
* @returns Object with availability, cloudStatus, and localOnly maps
*/
export function useConversionCloudStatus(): ConversionStatus {
const [status, setStatus] = useState<ConversionStatus>({
availability: {},
cloudStatus: {},
localOnly: {},
});
useEffect(() => {
const checkConversions = async () => {
// Don't check until backend is healthy
// This prevents showing incorrect status during startup
if (!tauriBackendService.isBackendHealthy()) {
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') {
// In non-SaaS modes, local endpoint checking handles everything
setStatus({ availability: {}, cloudStatus: {}, localOnly: {} });
return;
}
const availability: Record<string, boolean> = {};
const cloudStatus: Record<string, boolean> = {};
const localOnly: Record<string, boolean> = {};
// Collect all conversion pairs first, then check all in parallel
const pairs: [string, string, string][] = [];
for (const fromExt of Object.keys(EXTENSION_TO_ENDPOINT)) {
for (const toExt of Object.keys(EXTENSION_TO_ENDPOINT[fromExt] || {})) {
const endpointName = getEndpointName(fromExt, toExt);
if (endpointName) pairs.push([fromExt, toExt, endpointName]);
}
}
const results = await Promise.all(
pairs.map(async ([fromExt, toExt, endpointName]) => {
const key = `${fromExt}-${toExt}`;
try {
const combined = await endpointAvailabilityService.checkEndpointCombined(
endpointName,
tauriBackendService.getBackendUrl()
);
return { key, isAvailable: combined.isAvailable, willUseCloud: combined.willUseCloud, localOnly: combined.localOnly };
} catch (error) {
console.error(`[useConversionCloudStatus] Endpoint check failed for ${key}:`, error);
return { key, isAvailable: false, willUseCloud: false, localOnly: false };
}
})
);
for (const { key, isAvailable, willUseCloud: wuc, localOnly: lo } of results) {
availability[key] = isAvailable;
cloudStatus[key] = wuc;
localOnly[key] = lo;
}
setStatus({ availability, cloudStatus, localOnly });
};
// Initial check
checkConversions();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
checkConversions();
}
});
return unsubscribe;
}, []);
return status;
}
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { getToolCreditCost } from '@app/utils/creditCosts';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
import type { ToolId } from '@app/types/toolId';
/**
* Desktop implementation of credit checking for cloud operations.
* Hooks are called at render time; the returned checkCredits callback
* closes over the billing state so it can be called safely inside
* async operation handlers.
*
* Returns null when the operation is allowed, or an error message string
* when it should be blocked.
*/
export function useCreditCheck(operationType?: string) {
const billing = useSaaSBilling();
const { t } = useTranslation();
const checkCredits = useCallback(async (): Promise<string | null> => {
if (!billing) return null;
const { creditBalance, loading } = billing;
const requiredCredits = getToolCreditCost(operationType as ToolId);
if (!loading && creditBalance < requiredCredits) {
window.dispatchEvent(new CustomEvent(CREDIT_EVENTS.INSUFFICIENT, {
detail: {
operationType,
requiredCredits,
currentBalance: creditBalance,
},
}));
return t(
'credits.insufficient.brief',
'Insufficient credits. You need {{required}} credits but have {{current}}.',
{ required: requiredCredits, current: creditBalance },
);
}
return null;
}, [billing, operationType, t]);
return { checkCredits };
}
@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react';
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
/**
* Desktop hook that monitors credit balance and dispatches events
* when credits are exhausted or low
*/
export function useCreditEvents() {
const { creditBalance } = useSaaSBilling();
const prevBalanceRef = useRef(creditBalance);
useEffect(() => {
const prevBalance = prevBalanceRef.current;
// Dispatch exhausted event when credits reach 0 from positive balance
if (creditBalance <= 0 && prevBalance > 0) {
window.dispatchEvent(
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
detail: { previousBalance: prevBalance, currentBalance: creditBalance },
})
);
}
// Update ref for next comparison
prevBalanceRef.current = creditBalance;
}, [creditBalance]);
}
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { authService } from '@app/services/authService';
/**
* Shared hook for enabling metered (overage) billing via Supabase edge function.
* Used by CreditExhaustedModal and InsufficientCreditsModal to avoid duplicate logic.
*
* @param refreshBilling - Callback to refresh billing state after success
* @param onSuccess - Callback invoked after billing is enabled and refreshed
* @param logPrefix - Label used in console messages for easier tracing
*/
export function useEnableMeteredBilling(
refreshBilling: () => Promise<void>,
onSuccess: () => void,
logPrefix: string
): {
enablingMetering: boolean;
meteringError: string | null;
handleEnableMetering: () => Promise<void>;
} {
const [enablingMetering, setEnablingMetering] = useState(false);
const [meteringError, setMeteringError] = useState<string | null>(null);
const handleEnableMetering = async () => {
console.debug(`[${logPrefix}] Enabling metered billing`);
setEnablingMetering(true);
setMeteringError(null);
try {
const token = await authService.getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
const { data, error } = await supabase.functions.invoke('create-meter-subscription', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (error) {
throw new Error(error.message || 'Failed to enable metered billing');
}
if (!data?.success) {
throw new Error(data?.error || data?.message || 'Failed to enable metered billing');
}
console.debug(`[${logPrefix}] Metered billing enabled successfully`);
await refreshBilling();
onSuccess();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to enable metered billing';
console.error(`[${logPrefix}] Failed to enable metered billing:`, err);
setMeteringError(message);
} finally {
setEnablingMetering(false);
}
};
return { enablingMetering, meteringError, handleEnableMetering };
}
@@ -35,7 +35,8 @@ async function checkDependenciesReady(): Promise<boolean> {
suppressErrorToast: true,
});
return response.data?.dependenciesReady ?? false;
} catch {
} catch (error) {
console.debug('[useEndpointConfig] Dependencies not ready yet:', error);
return false;
}
}
@@ -50,7 +51,9 @@ export function useEndpointEnabled(endpoint: string): {
refetch: () => Promise<void>;
} {
const { t } = useTranslation();
const [enabled, setEnabled] = useState<boolean | null>(null);
// DESKTOP: Start optimistically as enabled (most desktop users are in SaaS mode)
// This prevents UI from being disabled while backend starts or checks are in progress
const [enabled, setEnabled] = useState<boolean | null>(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
@@ -92,7 +95,20 @@ export function useEndpointEnabled(endpoint: string): {
suppressErrorToast: true,
});
setEnabled(response.data);
const locallyEnabled = response.data;
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (!locallyEnabled) {
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
setEnabled(true); // Available via SaaS
return;
}
}
setEnabled(locallyEnabled);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
@@ -106,6 +122,15 @@ export function useEndpointEnabled(endpoint: string): {
}, RETRY_DELAY_MS);
}
} else {
// DESKTOP ENHANCEMENT: In SaaS mode, assume available even on check failure
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
setEnabled(true); // Available via SaaS
setError(null);
return;
}
setError(message);
setEnabled(false);
}
@@ -210,6 +235,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return acc;
}, {} as Record<string, boolean>);
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
// They will route to SaaS backend
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
for (const endpoint of disabledEndpoints) {
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
statusMap[endpoint] = true; // Mark as enabled via SaaS
details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
} catch (err: unknown) {
@@ -232,6 +270,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
acc.details[endpointName] = fallbackDetail;
return acc;
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
// DESKTOP ENHANCEMENT: In SaaS mode, mark all endpoints as available
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
for (const endpoint of endpoints) {
console.debug(`[useMultipleEndpointsEnabled] Endpoint ${endpoint} check failed but available via SaaS routing`);
fallbackStatus.status[endpoint] = true;
fallbackStatus.details[endpoint] = { enabled: true, reason: null };
}
}
setEndpointStatus(fallbackStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
}
+115
View File
@@ -0,0 +1,115 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { saasBillingService } from '@app/services/saasBillingService';
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
import type { TierLevel } from '@app/types/billing';
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: TierLevel;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: string[];
isContactOnly?: boolean;
overagePrice?: number;
}
export const useSaaSPlans = (currency: string = 'usd') => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { price: number; currency: string; overagePrice?: number }>>(new Map());
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const priceMap = await saasBillingService.getAvailablePlans(currency);
setDynamicPrices(priceMap);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
// Continue with default prices if fetch fails
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
const plans = useMemo<PlanTier[]>(() => {
const teamPlan = dynamicPrices.get('team');
return [
{
id: 'free',
name: t('plan.free.name', 'Free'),
price: 0,
currency: '$',
period: t('plan.period.month', '/month'),
highlights: FREE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: false },
{ name: t('plan.feature.api', 'API Access'), included: false },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
},
{
id: 'team',
name: t('plan.team.name', 'Team'),
price: teamPlan?.price || 10,
currency: teamPlan?.currency || '$',
period: t('plan.period.month', '/month'),
popular: true,
overagePrice: teamPlan?.overagePrice || 0.05,
highlights: TEAM_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
price: 0,
currency: '$',
period: '',
isContactOnly: true,
highlights: ENTERPRISE_PLAN_FEATURES.map(f => t(f.translationKey, f.defaultText)),
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Monthly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
]
}
];
}, [t, dynamicPrices]);
return {
plans,
loading,
error,
refetch: fetchPlans,
};
};
@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Desktop hook to check if a tool endpoint will use cloud backend
* @param endpointName - The endpoint name to check (e.g., 'ocr-pdf', 'compress-pdf')
* @returns true if the tool will use cloud credits, false otherwise
*/
export function useToolCloudStatus(endpointName?: string): boolean {
const [usesCloud, setUsesCloud] = useState(false);
useEffect(() => {
const checkCloudRouting = async () => {
if (!endpointName) {
setUsesCloud(false);
return;
}
try {
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isBackendHealthy()) {
setUsesCloud(false);
return;
}
// Check if in SaaS mode
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') {
setUsesCloud(false);
return;
}
// Check if supported on SaaS first (if not, no point showing cloud badge)
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointName);
if (!supportedOnSaaS) {
// Not available on SaaS, don't show cloud badge
setUsesCloud(false);
return;
}
// Available on SaaS, check if also available locally
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
tauriBackendService.getBackendUrl()
);
// Show cloud badge only if SaaS supports it but local doesn't
setUsesCloud(!supportedLocally);
} catch {
setUsesCloud(false);
}
};
// Initial check
checkCloudRouting();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
checkCloudRouting();
}
});
return unsubscribe;
}, [endpointName]);
return usesCloud;
}
@@ -0,0 +1,174 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
import { connectionModeService } from '@app/services/connectionModeService';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export interface ToolAvailabilityInfo {
available: boolean;
reason?: ToolDisableCause;
}
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: FileId[];
toolRegistry: Partial<ToolRegistry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
}
/**
* Desktop override of useToolManagement
* Enhances tool availability logic to consider SaaS backend routing
* - Tools not supported locally but available on SaaS are marked as available
* - In SaaS mode, tools can route to cloud backend
*/
export const useToolManagement = (): ToolManagementResult => {
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Start optimistically assuming SaaS mode (most common for desktop)
// This prevents tools from being incorrectly marked unavailable during initial load
const [isSaaSMode, setIsSaaSMode] = useState(true);
// Log that desktop version is being used
useEffect(() => {
console.debug('[useToolManagement] DESKTOP VERSION loaded - SaaS routing enabled');
}, []);
// Check connection mode
useEffect(() => {
connectionModeService.getCurrentMode().then(mode => {
setIsSaaSMode(mode === 'saas');
console.debug('[useToolManagement] Connection mode loaded:', mode);
});
// Subscribe to mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(config => {
setIsSaaSMode(config.mode === 'saas');
console.debug('[useToolManagement] Connection mode changed:', config.mode);
});
return unsubscribe;
}, []);
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled locally
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
// DESKTOP ENHANCEMENT: In SaaS mode, tools are available even if not supported locally
// They will route to the SaaS backend instead
if (!hasLocalSupport && isSaaSMode) {
console.debug(`[useToolManagement] Tool ${toolKey} not supported locally but available via SaaS routing`);
// In SaaS mode, assume tools can route to cloud if not available locally
// The operation router will handle the actual routing decision
return true;
}
if (!hasLocalSupport) {
console.debug(`[useToolManagement] Tool ${toolKey} not available - no local support and not in SaaS mode`, {
isSaaSMode,
endpoints,
endpointStatus: endpoints.map(e => ({ [e]: endpointStatus[e] }))
});
}
return hasLocalSupport;
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
}
const endpoints = tool.endpoints || [];
const disabledReasons: EndpointDisableReason[] = endpoints
.filter(endpoint => endpointStatus[endpoint] === false)
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
return 'missingDependency';
}
if (disabledReasons.some(reason => reason === 'CONFIG')) {
return 'disabledByAdmin';
}
if (disabledReasons.length > 0) {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
return {};
}
const availability: ToolAvailabilityMap = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const available = isToolAvailable(toolKey);
availability[toolKey] = available
? { available: true }
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const baseTool = baseRegistry[toolKey];
if (!baseTool) return;
const availabilityInfo = toolAvailability[toolKey];
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
// Check if tool is "coming soon" (has no component and no link)
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
}
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
});
return availableToolRegistry;
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
}, [toolRegistry]);
return {
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
toolSelectedFileIds,
toolRegistry,
setToolSelectedFileIds,
getSelectedTool,
toolAvailability,
};
};
@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react';
import { operationRouter } from '@app/services/operationRouter';
import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Desktop hook to detect if an operation will use cloud/SaaS backend
* @param endpoint - The API endpoint to check (e.g., '/api/v1/misc/compress-pdf')
* @returns true if the operation will use cloud credits, false otherwise
*/
export function useWillUseCloud(endpoint?: string): boolean {
const [willUseCloud, setWillUseCloud] = useState(false);
useEffect(() => {
const checkCloudRouting = async () => {
if (!endpoint) {
setWillUseCloud(false);
return;
}
// Don't show cloud badges until backend is healthy
// This prevents showing incorrect cloud status during startup
if (!tauriBackendService.isBackendHealthy()) {
setWillUseCloud(false);
return;
}
// Check if this endpoint will route to SaaS
try {
const willRoute = await operationRouter.willRouteToSaaS(endpoint);
setWillUseCloud(willRoute);
} catch (error) {
console.error('[useWillUseCloud] Failed to check cloud routing for endpoint:', endpoint, error);
setWillUseCloud(false);
}
};
// Initial check
checkCloudRouting();
// Subscribe to backend status changes to re-check when backend becomes healthy
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
if (status === 'healthy') {
checkCloudRouting();
}
});
return unsubscribe;
}, [endpoint]);
return willUseCloud;
}
+50 -12
View File
@@ -6,7 +6,7 @@ import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
@@ -18,6 +18,7 @@ interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
_retry?: boolean;
_isSaaSRequest?: boolean;
}
/**
@@ -36,9 +37,15 @@ export function setupApiInterceptors(client: AxiosInstance): void {
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedRequestConfig;
// IMPORTANT: Check backend readiness BEFORE modifying URL
// Pattern matching in shouldSkipBackendReadyCheck() needs original relative URL
const originalUrl = extendedConfig.url;
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const skipForSaaSBackend = await operationRouter.shouldSkipBackendReadyCheck(originalUrl);
try {
// Get the appropriate base URL for this request
const baseUrl = await operationRouter.getBaseUrl(extendedConfig.url);
const baseUrl = await operationRouter.getBaseUrl(originalUrl);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
@@ -50,23 +57,35 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests and enable credentials
// Determine if this request needs authentication
// - Local bundled backend: No auth (security disabled)
// - SaaS backend: Needs auth token
// - Self-hosted backend: Needs auth token
const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) {
// Self-hosted mode: enable credentials for session management
const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL;
const needsAuth = isRemote || isSaaSBackendRequest;
// Tag request so error handler can identify SaaS backend errors without URL matching
extendedConfig._isSaaSRequest = isSaaSBackendRequest;
console.debug(`[apiClientSetup] Auth check: isRemote=${isRemote}, isSaaSBackendRequest=${isSaaSBackendRequest}, needsAuth=${needsAuth}, baseUrl=${baseUrl}`);
if (needsAuth) {
// Enable credentials for session management
extendedConfig.withCredentials = true;
// If another request is already refreshing, wait before attaching token.
// If another request is already refreshing, wait before attaching token
await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
console.debug(`[apiClientSetup] Added auth token for request to: ${extendedConfig.url}`);
} else {
console.warn('[apiClientSetup] Self-hosted mode but no auth token available');
console.warn(`[apiClientSetup] No auth token available for: ${extendedConfig.url}`);
}
} else {
// SaaS mode: disable credentials (security disabled on local backend)
// Local bundled backend: disable credentials (security disabled)
extendedConfig.withCredentials = false;
}
} catch (error) {
@@ -76,10 +95,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
}
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isSaaS = await operationRouter.isSaaSMode();
const backendHealthy = tauriBackendService.isBackendHealthy();
const backendStatus = tauriBackendService.getBackendStatus();
const backendPort = tauriBackendService.getBackendPort();
if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
console.debug(`[apiClientSetup] Backend readiness check for ${extendedConfig.url}: isSaaS=${isSaaS}, skipCheck=${skipCheck}, skipForSaaSBackend=${skipForSaaSBackend}, backendHealthy=${backendHealthy}, backendStatus=${backendStatus}, backendPort=${backendPort}`);
if (isSaaS && !skipCheck && !skipForSaaSBackend && !backendHealthy) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
@@ -101,9 +124,24 @@ export function setupApiInterceptors(client: AxiosInstance): void {
(error) => Promise.reject(error)
);
// Response interceptor: Handle auth errors
// Response interceptor: Handle auth errors and update credits from headers
client.interceptors.response.use(
(response) => {
async (response) => {
// Check for credit balance update in response headers
// Backend includes X-Credits-Remaining header after operations that consume credits
const creditsHeader = response.headers['x-credits-remaining'];
if (creditsHeader !== undefined && creditsHeader !== null) {
const creditsRemaining = parseInt(creditsHeader, 10);
if (!isNaN(creditsRemaining)) {
// Dispatch event with new balance for immediate update
window.dispatchEvent(new CustomEvent('credits:updated', {
detail: { creditsRemaining }
}));
}
}
return response;
},
async (error) => {
@@ -1,6 +1,7 @@
import i18n from '@app/i18n';
import { alert } from '@app/components/toast';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { operationRouter } from '@app/services/operationRouter';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
@@ -8,8 +9,22 @@ let lastBackendToast = 0;
/**
* Desktop-specific guard that ensures the embedded backend is healthy
* before tools attempt to call any API endpoints.
* Enhanced to skip checks for endpoints routed to SaaS backend.
*
* @param endpoint - Optional endpoint path to check if it needs local backend
* @returns true if backend is ready OR endpoint will be routed to SaaS
*/
export async function ensureBackendReady(): Promise<boolean> {
export async function ensureBackendReady(endpoint?: string): Promise<boolean> {
// Skip waiting if endpoint will be routed to SaaS backend
if (endpoint) {
const skipCheck = await operationRouter.shouldSkipBackendReadyCheck(endpoint);
if (skipCheck) {
console.debug('[backendReadinessGuard] Skipping backend ready check (SaaS routing)');
return true;
}
}
// Check local backend health
if (tauriBackendService.isBackendHealthy()) {
return true;
}
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
export type ConnectionMode = 'saas' | 'selfhosted';
@@ -106,6 +107,11 @@ export class ConnectionModeService {
});
this.currentConfig = { mode: 'saas', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
console.log('Cleared endpoint availability cache due to connection mode change');
this.notifyListeners();
console.log('Switched to SaaS mode successfully');
@@ -120,6 +126,11 @@ export class ConnectionModeService {
});
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
console.log('Cleared endpoint availability cache due to connection mode change');
this.notifyListeners();
console.log('Switched to self-hosted mode successfully');
@@ -0,0 +1,283 @@
import { fetch } from '@tauri-apps/plugin-http';
import { STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
/**
* Service for checking endpoint availability on the local bundled backend.
* Used by operation router to determine if a tool should be routed to SaaS backend.
*
* Caches results to avoid repeated checks for the same endpoint.
*/
export class EndpointAvailabilityService {
private localCache: Map<string, boolean> = new Map();
private localCacheExpiry: Map<string, number> = new Map();
private saasCache: Map<string, boolean> = new Map();
private saasCacheExpiry: Map<string, number> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Check if local backend supports an endpoint
* Returns cached result if available, otherwise fetches from backend
*
* @param endpoint - The endpoint path to check (e.g., "/api/v1/misc/compress-pdf")
* @param backendUrl - The URL for the backend
* @returns Promise<boolean> - true if supported locally, false otherwise
*/
async isEndpointSupportedLocally(endpoint: string, backendUrl: string | null): Promise<boolean> {
// Check cache first
const cached = this.localCache.get(endpoint);
const expiry = this.localCacheExpiry.get(endpoint);
if (cached !== undefined && expiry && Date.now() < expiry) {
return cached;
}
// Fetch from backend
try {
if (!backendUrl) {
// Backend not started yet - assume not supported (will route to SaaS)
return false;
}
const url = `${backendUrl}/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpoint)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Cache-Control': 'no-store',
},
});
if (!response.ok) {
console.warn(
`[endpointAvailabilityService] Failed to check local endpoint availability: ${response.status}`
);
return false;
}
const data = await response.json();
const available = data[endpoint]?.enabled ?? false;
// Cache the result
this.localCache.set(endpoint, available);
this.localCacheExpiry.set(endpoint, Date.now() + this.CACHE_DURATION);
return available;
} catch (error) {
console.error(
`[endpointAvailabilityService] Error checking local endpoint availability:`,
error
);
return false; // Assume not supported on error
}
}
/**
* Check if SaaS backend supports an endpoint
* Returns cached result if available, otherwise fetches from SaaS backend
*
* @param endpoint - The endpoint path to check (e.g., "/api/v1/misc/compress-pdf")
* @returns Promise<boolean> - true if supported on SaaS, false otherwise
*/
async isEndpointSupportedOnSaaS(endpoint: string): Promise<boolean> {
// Check cache first
const cached = this.saasCache.get(endpoint);
const expiry = this.saasCacheExpiry.get(endpoint);
if (cached !== undefined && expiry && Date.now() < expiry) {
return cached;
}
// Fetch from SaaS backend
try {
if (!STIRLING_SAAS_BACKEND_API_URL) {
return false;
}
const saasUrl = STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
const url = `${saasUrl}/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpoint)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Cache-Control': 'no-store',
},
});
if (!response.ok) {
console.warn(
`[endpointAvailabilityService] Failed to check SaaS endpoint availability: ${response.status}`
);
return false;
}
const data = await response.json();
const available = data[endpoint]?.enabled ?? false;
// Cache the result
this.saasCache.set(endpoint, available);
this.saasCacheExpiry.set(endpoint, Date.now() + this.CACHE_DURATION);
return available;
} catch (error) {
console.error(
`[endpointAvailabilityService] Error checking SaaS endpoint availability:`,
error
);
return false; // Assume not supported on error
}
}
/**
* Clear cache (useful when backend restarts or connection mode changes)
*/
clearCache() {
this.localCache.clear();
this.localCacheExpiry.clear();
this.saasCache.clear();
this.saasCacheExpiry.clear();
}
/**
* Debug: Log all cached endpoint availability
* Call from console: window.endpointAvailabilityService.debugCache()
*/
debugCache() {
console.group('[endpointAvailabilityService] Cache Debug');
console.group('Local Cache');
this.localCache.forEach((available, endpoint) => {
const expiry = this.localCacheExpiry.get(endpoint);
const expiresIn = expiry ? Math.max(0, expiry - Date.now()) : 0;
console.log(`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`);
});
console.groupEnd();
console.group('SaaS Cache');
this.saasCache.forEach((available, endpoint) => {
const expiry = this.saasCacheExpiry.get(endpoint);
const expiresIn = expiry ? Math.max(0, expiry - Date.now()) : 0;
console.log(`${endpoint}: ${available} (expires in ${Math.round(expiresIn / 1000)}s)`);
});
console.groupEnd();
console.groupEnd();
}
/**
* Preload availability for multiple endpoints
* Optimizes batch checking for tool initialization
*
* @param endpoints - Array of endpoint paths to check
* @param backendUrl - The URL of the backend
*/
async preloadEndpoints(endpoints: string[], backendUrl: string | null): Promise<void> {
if (!backendUrl || endpoints.length === 0) {
return;
}
try {
const endpointsParam = endpoints.join(',');
const url = `${backendUrl}/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Cache-Control': 'no-store',
},
});
if (response.ok) {
const data = await response.json();
const now = Date.now();
Object.entries(data).forEach(([endpoint, details]: [string, any]) => {
const available = details?.enabled ?? false;
this.localCache.set(endpoint, available);
this.localCacheExpiry.set(endpoint, now + this.CACHE_DURATION);
});
} else {
console.warn(
`[endpointAvailabilityService] Failed to preload endpoints: ${response.status}`
);
}
} catch (error) {
console.error('[endpointAvailabilityService] Error preloading endpoints:', error);
}
}
/**
* Check endpoint availability across both local and SaaS backends
* Returns comprehensive status for decision making
*
* @param endpoint - The endpoint path to check
* @returns Promise with availability details
*/
async checkEndpointCombined(endpoint: string, backendUrl: string | null): Promise<{
availableLocally: boolean;
availableOnSaaS: boolean;
isAvailable: boolean; // local || saas
willUseCloud: boolean; // saas && !local
localOnly: boolean; // local && !saas
}> {
// Check both backends in parallel for efficiency
const [availableLocally, availableOnSaaS] = await Promise.all([
this.isEndpointSupportedLocally(endpoint, backendUrl),
this.isEndpointSupportedOnSaaS(endpoint),
]);
return {
availableLocally,
availableOnSaaS,
isAvailable: availableLocally || availableOnSaaS,
willUseCloud: availableOnSaaS && !availableLocally,
localOnly: availableLocally && !availableOnSaaS,
};
}
/**
* Get cache statistics (useful for debugging)
*/
getCacheStats(): {
local: { size: number; entries: Array<{ endpoint: string; available: boolean; expiresIn: number }> };
saas: { size: number; entries: Array<{ endpoint: string; available: boolean; expiresIn: number }> };
} {
const now = Date.now();
const localEntries = Array.from(this.localCache.entries()).map(([endpoint, available]) => {
const expiry = this.localCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
});
const saasEntries = Array.from(this.saasCache.entries()).map(([endpoint, available]) => {
const expiry = this.saasCacheExpiry.get(endpoint) ?? 0;
return {
endpoint,
available,
expiresIn: Math.max(0, expiry - now),
};
});
return {
local: {
size: this.localCache.size,
entries: localEntries,
},
saas: {
size: this.saasCache.size,
entries: saasEntries,
},
};
}
}
// Export singleton instance
export const endpointAvailabilityService = new EndpointAvailabilityService();
// Expose to window for debugging
if (typeof window !== 'undefined') {
(window as any).endpointAvailabilityService = endpointAvailabilityService;
}
@@ -1,5 +1,8 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
import { STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
import { CONVERSION_ENDPOINTS, ENDPOINT_NAMES } from '@app/constants/convertConstants';
export type ExecutionTarget = 'local' | 'remote';
@@ -38,13 +41,148 @@ export class OperationRouter {
return 'remote';
}
/**
* Check if endpoint should route to SaaS backend (not local)
* @param endpoint - The endpoint path to check
* @returns true if endpoint should route to SaaS backend
*/
private isSaaSBackendEndpoint(endpoint?: string): boolean {
if (!endpoint) return false;
const saasBackendPatterns = [
/^\/api\/v1\/team\//, // Team endpoints
/^\/api\/v1\/auth\//, // Auth endpoints (Supabase auth in SaaS mode)
// Add more SaaS-specific patterns here as needed
];
return saasBackendPatterns.some(pattern => pattern.test(endpoint));
}
/**
* Check if endpoint is a tool endpoint (vs team/admin/config endpoints)
* @param endpoint - The endpoint path to check
* @returns true if endpoint is a tool endpoint
*/
private isToolEndpoint(endpoint: string): boolean {
const toolPatterns = [
/^\/api\/v1\/general\//,
/^\/api\/v1\/convert\//,
/^\/api\/v1\/misc\//,
/^\/api\/v1\/security\//,
/^\/api\/v1\/filter\//,
/^\/api\/v1\/multi-tool\//,
/^\/api\/v1\/ui-data\//, // UI data endpoints for tools (e.g., OCR languages)
];
return toolPatterns.some(pattern => pattern.test(endpoint));
}
/**
* Extract endpoint name from endpoint path
* @param endpoint - The endpoint path
* @returns Endpoint name for backend capability checks
*
* Examples:
* - "/api/v1/ui-data/ocr-pdf" -> "ocr-pdf"
* - "/api/v1/misc/repair" -> "repair"
* - "/api/v1/general/merge-pdfs" -> "merge-pdfs"
* - "/api/v1/convert/pdf/presentation" -> "pdf-to-presentation"
*/
private extractEndpointName(endpoint: string): string {
// UI data endpoints: /api/v1/ui-data/{endpoint-name}
const uiDataMatch = endpoint.match(/^\/api\/v1\/ui-data\/(.+)$/);
if (uiDataMatch) {
return uiDataMatch[1];
}
// Convert endpoints: Use reverse lookup from actual constants
if (endpoint.startsWith('/api/v1/convert/')) {
// Find the key in CONVERSION_ENDPOINTS that matches this path
for (const [key, path] of Object.entries(CONVERSION_ENDPOINTS)) {
if (path === endpoint) {
// Use that key to get the endpoint name from ENDPOINT_NAMES
return ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES];
}
}
// Fallback to pattern-based extraction if not found in constants
const convertMatch = endpoint.match(/^\/api\/v1\/convert\/([^/]+)\/([^/]+)$/);
if (convertMatch) {
const [, from, to] = convertMatch;
return `${from}-to-${to}`;
}
}
// Tool operation endpoints: /api/v1/{category}/{endpoint-name}
const toolMatch = endpoint.match(/^\/api\/v1\/(?:general|misc|security|filter|multi-tool)\/(.+)$/);
if (toolMatch) {
return toolMatch[1];
}
// Not a recognized pattern, return as-is
return endpoint;
}
/**
* Gets the base URL for an operation based on execution target
* @param _operation - The operation name (for future operation classification)
* Enhanced with capability-based routing for tools not supported locally
* @param operation - The operation endpoint path (for endpoint classification)
* @returns Base URL for API calls
*/
async getBaseUrl(_operation?: string): Promise<string> {
const target = await this.getExecutionTarget(_operation);
async getBaseUrl(operation?: string): Promise<string> {
const mode = await connectionModeService.getCurrentMode();
// Always route team endpoints to SaaS backend (existing logic)
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
if (!STIRLING_SAAS_BACKEND_API_URL) {
throw new Error('VITE_SAAS_BACKEND_API_URL not configured');
}
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (team endpoint)`);
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
}
// NEW: Check if local backend supports this tool endpoint
if (mode === 'saas' && operation && this.isToolEndpoint(operation)) {
// Extract endpoint name for capability check (e.g., "/api/v1/misc/repair" -> "repair")
const endpointToCheck = this.extractEndpointName(operation);
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl()
);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
if (!supportedLocally) {
// Local backend doesn't support this - check if SaaS supports it
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
if (!supportedOnSaaS) {
// Neither local nor SaaS support this - throw error
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
throw new Error(
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
);
}
// SaaS supports it - route to SaaS backend
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
throw new Error(
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
'Please check your environment configuration.'
);
}
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
}
// Supported locally - continue with local backend
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
}
// Existing logic for local/remote routing
const target = await this.getExecutionTarget(operation);
if (target === 'local') {
// Use dynamically assigned port from backend service
@@ -83,6 +221,61 @@ export class OperationRouter {
return mode === 'saas';
}
/**
* Checks if an endpoint should skip the local backend readiness check
* Returns true if the endpoint routes to SaaS backend (not local backend)
* Enhanced to support capability-based routing
* @param endpoint - The endpoint path to check
* @returns Promise<boolean> - true if endpoint should skip backend readiness check
*/
async shouldSkipBackendReadyCheck(endpoint?: string): Promise<boolean> {
// Team endpoints always skip (existing logic)
if (this.isSaaSBackendEndpoint(endpoint)) {
return true;
}
// NEW: Skip if endpoint will be routed to SaaS due to local unavailability
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas' && endpoint && this.isToolEndpoint(endpoint)) {
// For UI data endpoints, extract the endpoint name
const endpointToCheck = this.extractEndpointName(endpoint);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl()
);
return !supportedLocally; // Skip check if not supported locally
}
return false;
}
/**
* Check if an endpoint will be routed to SaaS backend
* Used by UI to show "Cloud" badges on tools
* @param endpoint - The endpoint path to check
* @returns Promise<boolean> - true if endpoint will route to SaaS
*/
async willRouteToSaaS(endpoint: string): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') return false;
// Team endpoints always go to SaaS
if (this.isSaaSBackendEndpoint(endpoint)) return true;
// Tool endpoints go to SaaS if not supported locally
if (this.isToolEndpoint(endpoint)) {
// For UI data endpoints, extract the endpoint name
const endpointToCheck = this.extractEndpointName(endpoint);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl()
);
return !supportedLocally;
}
return false;
}
// Future enhancement: operation classification
// private isSimpleOperation(operation: string): boolean {
// const simpleOperations = [
@@ -0,0 +1,436 @@
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { supabase } from '@app/auth/supabase';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL, SUPABASE_KEY } from '@app/constants/connection';
import type { TierLevel, SubscriptionStatus, StripePlanId } from '@app/types/billing';
import { getCurrencySymbol } from '@app/config/billing';
/**
* Billing status returned from Supabase edge function
*/
export interface BillingStatus {
subscription: {
id: string;
status: SubscriptionStatus;
currentPeriodStart: number; // Unix timestamp
currentPeriodEnd: number; // Unix timestamp
} | null;
meterUsage: {
currentPeriodCredits: number; // Overage credits used
estimatedCost: number; // In cents
} | null;
tier: TierLevel;
isTrialing: boolean;
trialDaysRemaining?: number;
price?: number; // Monthly price (in dollars)
currency?: string; // Currency symbol (e.g., '$', '£')
creditBalance?: number; // Real-time remaining credits
}
/**
* Response from manage-billing edge function
*/
interface ManageBillingResponse {
url: string;
}
/**
* Plan pricing information
*/
export interface PlanPrice {
price: number;
currency: string;
overagePrice?: number;
}
/**
* Service for managing SaaS billing operations (Stripe + Supabase)
* Desktop-layer implementation using Tauri APIs for browser integration
*/
export class SaasBillingService {
private static instance: SaasBillingService;
static getInstance(): SaasBillingService {
if (!SaasBillingService.instance) {
SaasBillingService.instance = new SaasBillingService();
}
return SaasBillingService.instance;
}
/**
* Check if billing features are available (SaaS mode only)
*/
async isBillingAvailable(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
const isAuthenticated = await authService.isAuthenticated();
return mode === 'saas' && isAuthenticated;
} catch (error) {
console.error('[Desktop Billing] Failed to check billing availability:', error);
return false;
}
}
/**
* Fetch Pro plan price from Stripe
* Calls stripe-price-lookup edge function to get current pricing
*/
private async fetchPlanPrice(
token: string,
currencyCode: string = 'usd'
): Promise<{ price: number; currency: string }> {
try {
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, { unit_amount: number; currency: string }>;
missing: string[];
}>('stripe-price-lookup', {
headers: {
Authorization: `Bearer ${token}`,
},
body: {
lookup_keys: ['plan:pro'],
currency: currencyCode,
},
});
if (error) {
throw new Error(error.message || 'Failed to fetch plan price');
}
if (!data || !data.prices) {
throw new Error('No pricing data returned');
}
const proPrice = data.prices['plan:pro'];
if (proPrice) {
const price = proPrice.unit_amount / 100; // Convert cents to dollars
const currency = getCurrencySymbol(proPrice.currency);
return { price, currency };
}
// Fallback if price not found
throw new Error('Pro plan price not found');
} catch (error) {
console.error('[Desktop Billing] Error fetching plan price:', error);
throw error;
}
}
/**
* Fetch billing status from Supabase edge function
* Calls get-usage-billing which returns subscription + meter usage data
*/
async getBillingStatus(): Promise<BillingStatus> {
// Check if in SaaS mode
const isAvailable = await this.isBillingAvailable();
if (!isAvailable) {
throw new Error('Billing is only available in SaaS mode');
}
// Get JWT token for authentication
const token = await authService.getAuthToken();
if (!token) {
throw new Error('No authentication token available');
}
try {
// Call RPC via REST API using Tauri fetch (Supabase client RPC may not work in Tauri)
const rpcUrl = `${STIRLING_SAAS_URL}/rest/v1/rpc/get_user_billing_status`;
const response = await tauriFetch(rpcUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY || '',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Desktop Billing] RPC error response:', errorText);
throw new Error(`RPC call failed: ${response.status} ${response.statusText}`);
}
// RPC may return an array or a single object — normalise to array then take first element
const raw = await response.json() as unknown;
const billingDataArray = Array.isArray(raw) ? raw : (raw ? [raw] : []);
const billingData = billingDataArray[0] as {
user_id: string;
has_metered_billing_enabled: boolean;
is_pro: boolean;
} | undefined;
// Determine tier based on pro status
const isPro = billingData?.is_pro || false;
const tier: BillingStatus['tier'] = isPro ? 'team' : 'free';
// Fetch additional subscription details if pro
let subscription: BillingStatus['subscription'] = null;
let meterUsage: BillingStatus['meterUsage'] = null;
let isTrialing = false;
let trialDaysRemaining: number | undefined;
let price: number | undefined;
let currency: string | undefined;
if (isPro) {
// Fetch usage details
try {
const { data: usageData, error: usageError } = await supabase.functions.invoke<{
subscription: BillingStatus['subscription'];
meterUsage: BillingStatus['meterUsage'];
}>('get-usage-billing', {
headers: {
Authorization: `Bearer ${token}`,
},
body: {},
});
if (!usageError && usageData) {
subscription = usageData.subscription;
meterUsage = usageData.meterUsage;
if (subscription?.status === 'trialing') {
isTrialing = true;
const trialEnd = subscription.currentPeriodEnd;
const now = Math.floor(Date.now() / 1000);
trialDaysRemaining = Math.ceil((trialEnd - now) / (24 * 60 * 60));
}
}
} catch (usageError) {
console.warn('[Desktop Billing] Failed to fetch usage data:', usageError);
}
// Fetch the current Pro plan price from Stripe
try {
const priceData = await this.fetchPlanPrice(token, 'usd');
price = priceData.price;
currency = priceData.currency;
} catch (error) {
console.warn('[Desktop Billing] Failed to fetch plan price, using default:', error);
// Fallback to default pricing
price = 10;
currency = '$';
}
}
// Fetch credit balance for all authenticated users (both Pro and Free)
// Use backend API endpoint /api/v1/credits (same as SaaS web)
let creditBalance: number | undefined;
try {
const creditsEndpoint = `${STIRLING_SAAS_BACKEND_API_URL}/api/v1/credits`;
const creditResponse = await tauriFetch(creditsEndpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (creditResponse.ok) {
const creditData = await creditResponse.json();
// Backend returns { totalAvailableCredits: number, ... }
const credits = creditData?.totalAvailableCredits;
creditBalance = typeof credits === 'number' ? credits : 0;
} else {
const errorText = await creditResponse.text();
console.warn('[Desktop Billing] Failed to fetch credit balance:', creditResponse.status, errorText);
creditBalance = 0;
}
} catch (error) {
console.error('[Desktop Billing] Error fetching credit balance:', error);
creditBalance = 0;
}
const billingStatus: BillingStatus = {
subscription,
meterUsage,
tier,
isTrialing,
trialDaysRemaining,
price,
currency,
creditBalance,
};
return billingStatus;
} catch (error) {
console.error('[Desktop Billing] Failed to fetch billing status:', error);
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to fetch billing status');
}
}
/**
* Open Stripe billing portal in system browser
* Calls manage-billing edge function to get portal URL
*/
async openBillingPortal(returnUrl: string): Promise<void> {
// Check if in SaaS mode
const isAvailable = await this.isBillingAvailable();
if (!isAvailable) {
throw new Error('Billing portal is only available in SaaS mode');
}
// Get JWT token for authentication
const token = await authService.getAuthToken();
if (!token) {
throw new Error('No authentication token available');
}
try {
// Call Supabase edge function to get Stripe portal URL
const { data, error } = await supabase.functions.invoke<ManageBillingResponse>('manage-billing', {
headers: {
Authorization: `Bearer ${token}`,
},
body: {
return_url: returnUrl,
},
});
if (error) {
console.error('[Desktop Billing] Error creating billing portal session:', error);
throw new Error(error.message || 'Failed to create billing portal session');
}
if (!data || !data.url) {
throw new Error('No portal URL returned from manage-billing');
}
// Open in system browser (same pattern as OAuth)
await shellOpen(data.url);
} catch (error) {
console.error('[Desktop Billing] Failed to open billing portal:', error);
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to open billing portal');
}
}
/**
* Fetch available plan pricing from Stripe
* Calls stripe-price-lookup edge function to get current pricing for all plans
*/
async getAvailablePlans(currencyCode: string = 'usd'): Promise<Map<string, PlanPrice>> {
// Check if in SaaS mode
const isAvailable = await this.isBillingAvailable();
if (!isAvailable) {
throw new Error('Billing is only available in SaaS mode');
}
// Get JWT token for authentication
const token = await authService.getAuthToken();
if (!token) {
throw new Error('No authentication token available');
}
try {
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, { unit_amount: number; currency: string }>;
missing: string[];
}>('stripe-price-lookup', {
headers: {
Authorization: `Bearer ${token}`,
},
body: {
lookup_keys: ['plan:pro', 'meter:overage'],
currency: currencyCode,
},
});
if (error) {
throw new Error(error.message || 'Failed to fetch plan pricing');
}
if (!data || !data.prices) {
throw new Error('No pricing data returned');
}
// Map prices with currency symbols
const plans = new Map<string, PlanPrice>();
const proPrice = data.prices['plan:pro'];
const overagePrice = data.prices['meter:overage'];
if (proPrice) {
plans.set('team', {
price: proPrice.unit_amount / 100,
currency: getCurrencySymbol(proPrice.currency),
overagePrice: overagePrice ? overagePrice.unit_amount / 100 : 0.05,
});
}
return plans;
} catch (error) {
console.error('[Desktop Billing] Error fetching available plans:', error);
throw error;
}
}
/**
* Open Stripe checkout for plan upgrades in system browser
* Creates hosted checkout session and opens in browser
*/
async openCheckout(planId: StripePlanId, returnUrl: string): Promise<void> {
// Check if in SaaS mode
const isAvailable = await this.isBillingAvailable();
if (!isAvailable) {
throw new Error('Checkout is only available in SaaS mode');
}
// Get JWT token for authentication
const token = await authService.getAuthToken();
if (!token) {
throw new Error('No authentication token available');
}
try {
// Call Supabase edge function to create checkout session
// Use 'hosted' mode for browser redirect instead of 'embedded'
const { data, error } = await supabase.functions.invoke<{ url: string }>('create-checkout', {
headers: {
Authorization: `Bearer ${token}`,
},
body: {
ui_mode: 'hosted',
success_url: `${returnUrl}/checkout/success`,
cancel_url: `${returnUrl}/checkout/cancel`,
purchase_type: 'subscription',
plan: planId,
},
});
if (error) {
console.error('[Desktop Billing] Error creating checkout session:', error);
throw new Error(error.message || 'Failed to create checkout session');
}
if (!data || !data.url) {
console.error('[Desktop Billing] Invalid response data:', data);
throw new Error('No checkout URL returned from create-checkout');
}
// Open in system browser (same pattern as billing portal)
await shellOpen(data.url);
} catch (error) {
console.error('[Desktop Billing] Failed to create checkout session:', error);
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to create checkout session');
}
}
}
export const saasBillingService = SaasBillingService.getInstance();
@@ -0,0 +1,29 @@
import { extractAxiosErrorMessage } from '@app/services/httpErrorUtils';
import { alert } from '@app/components/toast';
/**
* Desktop implementation: intercepts errors from SaaS backend requests
* and shows a specific "Cloud Processing Failed" alert.
*
* _isSaaSRequest is set by the desktop apiClientSetup interceptor when
* a request is routed to the SaaS backend instead of the local backend.
*
* Returns true if the error was handled (suppresses further processing),
* false if this is not a SaaS error.
*/
export function handleSaaSError(error: unknown): boolean {
if ((error as any)?.config?._isSaaSRequest !== true) return false;
const { title: originalTitle, body: originalBody } = extractAxiosErrorMessage(error);
alert({
alertType: 'error',
title: 'Cloud Processing Failed',
body: `This tool requires cloud processing but encountered an error: ${originalBody}. Please check your connection and try again.`,
expandable: true,
isPersistentPopup: false,
});
console.error('[saasErrorInterceptor] SaaS backend error:', { originalTitle, originalBody });
return true;
}
@@ -159,10 +159,12 @@ export class TauriBackendService {
} else {
// SaaS mode - check bundled local backend
if (!this.backendStarted) {
console.debug('[TauriBackendService] Health check: backend not started');
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Health check: backend port not available');
return false;
}
baseUrl = `http://localhost:${this.backendPort}`;
@@ -171,6 +173,7 @@ export class TauriBackendService {
// Check if backend is ready (dependencies checked)
try {
const configUrl = `${baseUrl}/api/v1/config/app-config`;
console.debug(`[TauriBackendService] Checking backend health at: ${configUrl}`);
// For self-hosted mode, include auth token if available
const headers: Record<string, string> = {};
@@ -178,6 +181,9 @@ export class TauriBackendService {
const token = await this.getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
console.debug(`[TauriBackendService] Adding auth token to health check`);
} else {
console.debug(`[TauriBackendService] No auth token available for health check`);
}
}
@@ -187,16 +193,24 @@ export class TauriBackendService {
headers,
});
console.debug(`[TauriBackendService] Health check response: status=${response.status}, ok=${response.ok}`);
if (!response.ok) {
console.warn(`[TauriBackendService] Health check failed: response not ok (status ${response.status})`);
this.setStatus('unhealthy');
return false;
}
const data = await response.json();
console.debug(`[TauriBackendService] Health check data:`, data);
const dependenciesReady = data.dependenciesReady === true;
console.debug(`[TauriBackendService] dependenciesReady=${dependenciesReady}`);
this.setStatus(dependenciesReady ? 'healthy' : 'starting');
return dependenciesReady;
} catch {
} catch (error) {
console.error('[TauriBackendService] Health check error:', error);
this.setStatus('unhealthy');
return false;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Shared types for SaaS billing system
* Used across billing service, contexts, and components
*/
/**
* Plan tier levels (string union for tier identification)
*/
export type TierLevel = 'free' | 'team' | 'enterprise';
/**
* Stripe subscription status values
* @see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
*/
export type SubscriptionStatus =
| 'active'
| 'trialing'
| 'past_due'
| 'canceled'
| 'incomplete'
| 'incomplete_expired'
| 'unpaid';
/**
* Stripe plan IDs (Stripe API naming)
* Note: UI uses 'team' but Stripe uses 'pro'
*/
export type StripePlanId = 'pro' | 'enterprise';
/**
* Plan IDs used in the application (UI naming)
*/
export type PlanId = 'team' | 'enterprise';
+92
View File
@@ -0,0 +1,92 @@
import { ToolId } from '@app/types/toolId';
// Credit costs based on ResourceWeight enum from backend
export const CREDIT_COSTS = {
NONE: 0,
SMALL: 1,
MEDIUM: 3,
LARGE: 5,
XLARGE: 10,
} as const;
/**
* Mapping of tool IDs to their credit costs
* Based on backend ResourceWeight annotations
*/
export const TOOL_CREDIT_COSTS: Record<ToolId, number> = {
// No cost operations (0 credits)
showJS: CREDIT_COSTS.NONE,
devApi: CREDIT_COSTS.NONE,
devFolderScanning: CREDIT_COSTS.NONE,
devSsoGuide: CREDIT_COSTS.NONE,
devAirgapped: CREDIT_COSTS.NONE,
// Small operations (1 credit)
rotate: CREDIT_COSTS.SMALL,
removePages: CREDIT_COSTS.SMALL,
addText: CREDIT_COSTS.SMALL,
addPassword: CREDIT_COSTS.SMALL,
removePassword: CREDIT_COSTS.SMALL,
changePermissions: CREDIT_COSTS.SMALL,
flatten: CREDIT_COSTS.SMALL,
repair: CREDIT_COSTS.SMALL,
unlockPDFForms: CREDIT_COSTS.SMALL,
crop: CREDIT_COSTS.SMALL,
addPageNumbers: CREDIT_COSTS.SMALL,
extractPages: CREDIT_COSTS.SMALL,
reorganizePages: CREDIT_COSTS.SMALL,
scalePages: CREDIT_COSTS.SMALL,
editTableOfContents: CREDIT_COSTS.SMALL,
sign: CREDIT_COSTS.SMALL,
removeAnnotations: CREDIT_COSTS.SMALL,
removeImage: CREDIT_COSTS.SMALL,
scannerImageSplit: CREDIT_COSTS.SMALL,
adjustContrast: CREDIT_COSTS.SMALL,
multiTool: CREDIT_COSTS.SMALL,
compare: CREDIT_COSTS.SMALL,
addAttachments: CREDIT_COSTS.SMALL,
getPdfInfo: CREDIT_COSTS.MEDIUM,
validateSignature: CREDIT_COSTS.SMALL,
read: CREDIT_COSTS.SMALL,
// Medium operations (3 credits)
split: CREDIT_COSTS.MEDIUM,
merge: CREDIT_COSTS.MEDIUM,
pdfTextEditor: CREDIT_COSTS.MEDIUM,
changeMetadata: CREDIT_COSTS.MEDIUM,
watermark: CREDIT_COSTS.MEDIUM,
bookletImposition: CREDIT_COSTS.MEDIUM,
pdfToSinglePage: CREDIT_COSTS.MEDIUM,
removeBlanks: CREDIT_COSTS.MEDIUM,
autoRename: CREDIT_COSTS.MEDIUM,
sanitize: CREDIT_COSTS.MEDIUM,
addImage: CREDIT_COSTS.MEDIUM,
addStamp: CREDIT_COSTS.MEDIUM,
extractImages: CREDIT_COSTS.MEDIUM,
overlayPdfs: CREDIT_COSTS.MEDIUM,
pageLayout: CREDIT_COSTS.MEDIUM,
redact: CREDIT_COSTS.MEDIUM,
removeCertSign: CREDIT_COSTS.MEDIUM,
scannerEffect: CREDIT_COSTS.MEDIUM,
replaceColor: CREDIT_COSTS.MEDIUM,
annotate: CREDIT_COSTS.MEDIUM,
formFill: CREDIT_COSTS.MEDIUM,
// Large operations (5 credits)
compress: CREDIT_COSTS.LARGE,
convert: CREDIT_COSTS.LARGE,
ocr: CREDIT_COSTS.LARGE,
certSign: CREDIT_COSTS.LARGE,
// Extra large operations (10 credits)
automate: CREDIT_COSTS.XLARGE,
};
/**
* Get the credit cost for a specific tool
* @param toolId - The tool identifier
* @returns The credit cost for the tool, defaults to MEDIUM if not found
*/
export const getToolCreditCost = (toolId: ToolId): number => {
return TOOL_CREDIT_COSTS[toolId] ?? CREDIT_COSTS.MEDIUM;
};
+1
View File
@@ -22,6 +22,7 @@ export default defineConfig(({ mode }) => {
const requiredEnvVars = [
'VITE_SAAS_SERVER_URL',
'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY',
'VITE_SAAS_BACKEND_API_URL',
];
const missingVars = requiredEnvVars.filter(varName => !env[varName]);