diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 5ec256d57..3cf77bb64 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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 diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 4622bdb68..59790ee5d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -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") diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 39d5a42b1..b7ba6db49 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx new file mode 100644 index 000000000..0c595d5e7 --- /dev/null +++ b/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx @@ -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; +} diff --git a/frontend/src/core/components/shared/CloudBadge.tsx b/frontend/src/core/components/shared/CloudBadge.tsx new file mode 100644 index 000000000..3d01ebdf7 --- /dev/null +++ b/frontend/src/core/components/shared/CloudBadge.tsx @@ -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 +} diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index cac8704f7..8c1ae4f03 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -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((_, ref) => { const { t } = useTranslation(); @@ -265,6 +266,8 @@ const QuickAccessBar = forwardRef((_, ref) => { {/* Spacer to push bottom buttons to bottom */}
+ + {/* Bottom section */} {bottomButtons.map((buttonConfig, index) => { diff --git a/frontend/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx b/frontend/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx new file mode 100644 index 000000000..e27cfffe3 --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/SaaSTeamsSection.tsx @@ -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; +} diff --git a/frontend/src/core/components/shared/config/configSections/SaasPlanSection.tsx b/frontend/src/core/components/shared/config/configSections/SaasPlanSection.tsx new file mode 100644 index 000000000..a11c93f02 --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/SaasPlanSection.tsx @@ -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; +} diff --git a/frontend/src/core/components/shared/modals/CreditExhaustedModal.tsx b/frontend/src/core/components/shared/modals/CreditExhaustedModal.tsx new file mode 100644 index 000000000..5c3953d5c --- /dev/null +++ b/frontend/src/core/components/shared/modals/CreditExhaustedModal.tsx @@ -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; +} diff --git a/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx b/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx new file mode 100644 index 000000000..9610f655c --- /dev/null +++ b/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx @@ -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; +} diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx index b21f4224a..2f3459a43 100644 --- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx @@ -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', { diff --git a/frontend/src/core/components/tools/convert/GroupedFormatDropdown.tsx b/frontend/src/core/components/tools/convert/GroupedFormatDropdown.tsx index 8195cd701..e4b9a040a 100644 --- a/frontend/src/core/components/tools/convert/GroupedFormatDropdown.tsx +++ b/frontend/src/core/components/tools/convert/GroupedFormatDropdown.tsx @@ -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 && ( + + )} ))} diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index 656c4d195..45ccf5fbb 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -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 && ( + + + + )} ); diff --git a/frontend/src/core/components/tools/shared/createToolFlow.tsx b/frontend/src/core/components/tools/shared/createToolFlow.tsx index 4ddaae67c..ac5dd0661 100644 --- a/frontend/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/core/components/tools/shared/createToolFlow.tsx @@ -38,6 +38,7 @@ export interface ExecuteButtonConfig { isVisible?: boolean; disabled?: boolean; testId?: string; + showCloudBadge?: boolean; } export interface ReviewStepConfig { @@ -105,6 +106,7 @@ export function createToolFlow(config: ToolFlowConfig diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index 7fc8d21f0..529b895c5 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -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 = ({ 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 = ({ id, tool, isSelected, onSelect, {t('toolPanel.alpha', 'Alpha')} )} + {usesCloud && !isUnavailable && ( + + )}
{matchedSynonym && ( Promise; + acceptInvitation: (invitationId: string) => Promise; + rejectInvitation: (invitationId: string) => Promise; + cancelInvitation: (invitationId: string) => Promise; + removeMember: (teamId: string, memberId: string) => Promise; + leaveTeam: (teamId: string) => Promise; + refreshTeams: () => Promise; +} + +const SaaSTeamContext = createContext(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 }; diff --git a/frontend/src/core/contexts/SaasBillingContext.tsx b/frontend/src/core/contexts/SaasBillingContext.tsx new file mode 100644 index 000000000..8e16044fa --- /dev/null +++ b/frontend/src/core/contexts/SaasBillingContext.tsx @@ -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}; +} diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index cf805fc9c..98ead906f 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -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({ + const operation = useToolOperation({ ...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, + }; }; diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 58ee11540..54ad226d8 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -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 { status: string; errorMessage: string | null; progress: ProcessingProgress | null; + willUseCloud?: boolean; // Actions executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; @@ -183,6 +186,16 @@ export const useToolOperation = ( const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); 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 = ( 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 = ( 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 = ( status: state.status, errorMessage: state.errorMessage, progress: state.progress, + willUseCloud, // Actions executeOperation, diff --git a/frontend/src/core/hooks/useConversionCloudStatus.ts b/frontend/src/core/hooks/useConversionCloudStatus.ts new file mode 100644 index 000000000..1c337212b --- /dev/null +++ b/frontend/src/core/hooks/useConversionCloudStatus.ts @@ -0,0 +1,21 @@ +/** + * Comprehensive conversion status data + */ +export interface ConversionStatus { + availability: Record; // Available on local OR SaaS? + cloudStatus: Record; // Will use cloud? + localOnly: Record; // 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 +} diff --git a/frontend/src/core/hooks/useCreditCheck.ts b/frontend/src/core/hooks/useCreditCheck.ts new file mode 100644 index 000000000..642859e14 --- /dev/null +++ b/frontend/src/core/hooks/useCreditCheck.ts @@ -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 => null, // null = allowed + }; +} diff --git a/frontend/src/core/hooks/useToolCloudStatus.ts b/frontend/src/core/hooks/useToolCloudStatus.ts new file mode 100644 index 000000000..5e880e982 --- /dev/null +++ b/frontend/src/core/hooks/useToolCloudStatus.ts @@ -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 +} diff --git a/frontend/src/core/hooks/useWillUseCloud.ts b/frontend/src/core/hooks/useWillUseCloud.ts new file mode 100644 index 000000000..569d58ee7 --- /dev/null +++ b/frontend/src/core/hooks/useWillUseCloud.ts @@ -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 +} diff --git a/frontend/src/core/services/backendReadinessGuard.ts b/frontend/src/core/services/backendReadinessGuard.ts index 17f73de11..5ae51b36a 100644 --- a/frontend/src/core/services/backendReadinessGuard.ts +++ b/frontend/src/core/services/backendReadinessGuard.ts @@ -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 { +export async function ensureBackendReady(_endpoint?: string): Promise { return true; } diff --git a/frontend/src/core/services/httpErrorHandler.ts b/frontend/src/core/services/httpErrorHandler.ts index f5b24cc14..68ca06ae1 100644 --- a/frontend/src/core/services/httpErrorHandler.ts +++ b/frontend/src/core/services/httpErrorHandler.ts @@ -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 = {}; @@ -126,6 +52,9 @@ export async function handleHttpError(error: any): Promise { 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); diff --git a/frontend/src/core/services/httpErrorUtils.ts b/frontend/src/core/services/httpErrorUtils.ts new file mode 100644 index 000000000..4f54aff70 --- /dev/null +++ b/frontend/src/core/services/httpErrorUtils.ts @@ -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 }; + } +} diff --git a/frontend/src/core/services/saasErrorInterceptor.ts b/frontend/src/core/services/saasErrorInterceptor.ts new file mode 100644 index 000000000..16e263b4e --- /dev/null +++ b/frontend/src/core/services/saasErrorInterceptor.ts @@ -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; +} diff --git a/frontend/src/core/tools/Convert.tsx b/frontend/src/core/tools/Convert.tsx index 82852f36d..16782c982 100644 --- a/frontend/src/core/tools/Convert.tsx +++ b/frontend/src/core/tools/Convert.tsx @@ -19,7 +19,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const scrollContainerRef = useRef(null); const convertParams = useConvertParameters(); - const convertOperation = useConvertOperation(); + const convertOperation = useConvertOperation(convertParams.parameters); const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName()); diff --git a/frontend/src/desktop/auth/supabase.ts b/frontend/src/desktop/auth/supabase.ts new file mode 100644 index 000000000..293521182 --- /dev/null +++ b/frontend/src/desktop/auth/supabase.ts @@ -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 + }, + } +); diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 6e1455d02..f795b50fd 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -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, }} > - - - - {children} + + + + + + + + {children} + + + ); } diff --git a/frontend/src/desktop/components/DesktopBannerInitializer.tsx b/frontend/src/desktop/components/DesktopBannerInitializer.tsx index 0a1546dc0..68d2728c1 100644 --- a/frontend/src/desktop/components/DesktopBannerInitializer.tsx +++ b/frontend/src/desktop/components/DesktopBannerInitializer.tsx @@ -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( <> + , diff --git a/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx new file mode 100644 index 000000000..7c10b97fb --- /dev/null +++ b/frontend/src/desktop/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx @@ -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 ( + + + + {creditBalance} {creditBalance === 1 ? 'credit' : 'credits'} + + + Upgrade + + + + ); +} diff --git a/frontend/src/desktop/components/shared/CloudBadge.tsx b/frontend/src/desktop/components/shared/CloudBadge.tsx new file mode 100644 index 000000000..ebd7e06bf --- /dev/null +++ b/frontend/src/desktop/components/shared/CloudBadge.tsx @@ -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 ( + + } + variant="light" + color="blue" + size="xs" + > + + + ); +} diff --git a/frontend/src/desktop/components/shared/TeamInvitationBanner.tsx b/frontend/src/desktop/components/shared/TeamInvitationBanner.tsx new file mode 100644 index 000000000..24bc0cb1e --- /dev/null +++ b/frontend/src/desktop/components/shared/TeamInvitationBanner.tsx @@ -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(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 = ( + + {invitation.inviterEmail} {t('team.invitationBanner.message', 'has invited you to join')}{' '} + {invitation.teamName} + + ); + + const actionButtons = ( + + + + + ); + + return ( + + {message} + {actionButtons} + + } + 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)" + /> + ); +} diff --git a/frontend/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx b/frontend/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx new file mode 100644 index 000000000..e8d9fb86f --- /dev/null +++ b/frontend/src/desktop/components/shared/billing/SaaSStripeCheckout.tsx @@ -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 = ({ + opened, + onClose, + planId, + onSuccess +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ 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 ( +
+ + + {t('payment.preparing', 'Preparing your checkout...')} + +
+ ); + + case 'opened': + return ( + }> + + + {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.')} + + + + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + 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 ( + + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: getPlanName() })} + + + } + size="md" + centered + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; diff --git a/frontend/src/desktop/components/shared/config/configNavSections.tsx b/frontend/src/desktop/components/shared/config/configNavSections.tsx index d5daddfa7..52635ab3c 100644 --- a/frontend/src/desktop/components/shared/config/configNavSections.tsx +++ b/frontend/src/desktop/components/shared/config/configNavSections.tsx @@ -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(false); + const [isAuthenticated, setIsAuthenticated] = useState(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: , + }, + ], + }); + result.push({ + title: t('settings.team.title', 'Team'), + items: [ + { + key: 'teams', + label: t('settings.team.title', 'Team'), + icon: 'groups-rounded', + component: , + }, + ], + }); + } + + // 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: , + }, + ], + }); + return sections; }; diff --git a/frontend/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx b/frontend/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx new file mode 100644 index 000000000..35a0b74fb --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( + + {t('team.loading', 'Loading team information...')} + + ); + } + + return ( + + {/* Header */} +
+ +
+ {isEditingName ? ( + + 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(); + }} + /> + + + + + + + + ) : ( + + {currentTeam.name} + {isTeamLeader && !isPersonalTeam && ( + + + + )} + {isTeamLeader && {t('team.leader', 'LEADER')}} + {isPersonalTeam && ( + {t('team.personal', 'Personal')} + )} + + )} + {!isEditingName && !isPersonalTeam && ( + + {t('team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })} + + )} +
+ {!isPersonalTeam && !isTeamLeader && !isEditingName && ( + + )} +
+
+ + {/* Upgrade Banner for Free Users */} + {isPersonalTeam && !isPro && ( + }> + +
+ {t('team.upgrade.title', 'Upgrade to Pro to unlock team features')} + + {t('team.upgrade.description', 'Invite members, share credits, and more.')}{' '} + setFeaturesModalOpened(true)} style={{ cursor: 'pointer' }}> + {t('common.learnMore', 'Learn more')} + + +
+ +
+
+ )} + + {/* Team Features Modal */} + setFeaturesModalOpened(false)} + size="md" + centered + padding="xl" + withCloseButton={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > +
+ setFeaturesModalOpened(false)} + size="lg" + style={{ + position: 'absolute', + top: -8, + right: -8, + zIndex: 1 + }} + /> + + {/* Header */} + + {t('team.features.badge', 'PRO FEATURE')} + + {t('team.features.title', 'Team Collaboration')} + + + {t('team.features.subtitle', 'Upgrade to Pro and unlock powerful team features')} + + + + {/* Features List */} + + + + } + > + + {t('team.features.invite.title', 'Invite team members')} + {t('team.features.invite.description', 'Add unlimited users with additional seat purchases')} + + + {t('team.features.credits.title', 'Share credits across your team')} + {t('team.features.credits.description', 'Pool resources for collaborative work')} + + + {t('team.features.dashboard.title', 'Team management dashboard')} + {t('team.features.dashboard.description', 'Control permissions, monitor usage, and manage members')} + + + {t('team.features.billing.title', 'Centralized billing')} + {t('team.features.billing.description', 'One invoice for all team seats and usage')} + + + + {/* CTA Button */} + + +
+
+ + {/* Error/Success Messages */} + {error && ( + setError(null)} withCloseButton> + {error} + + )} + + {success && ( + setSuccess(null)} withCloseButton> + {success} + + )} + + {/* Invite Members (Pro Users) */} + {isTeamLeader && isPro && ( +
+ {t('team.invite.title', 'Invite Team Member')} +
+ + setInviteEmail(e.target.value)} + style={{ flex: 1 }} + required + error={inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) ? t('team.invite.invalidEmail', 'Invalid email format') : undefined} + /> + + +
+
+ )} + + {/* Team Members Table */} +
+ {t('team.members.title', 'Team Members')} + + + + + {t('team.members.nameColumn', 'Name')} + + + {t('team.members.emailColumn', 'Email')} + + + {t('team.members.roleColumn', 'Role')} + + {isTeamLeader && !isPersonalTeam && ( + + )} + + + + {teamMembers.length === 0 && teamInvitations.length === 0 ? ( + + + + {t('team.members.empty', 'No team members yet.')} + + + + ) : ( + <> + {/* Active Members */} + {teamMembers.map((member) => ( + + + + {member.username} + + + + + {member.email} + + + + + {member.role} + + + {isTeamLeader && !isPersonalTeam && ( + + {member.role !== 'LEADER' && ( + + + + + + + + } + onClick={() => handleRemove(member.id, member.email)} + > + {t('team.members.remove', 'Remove from Team')} + + + + )} + + )} + + ))} + + {/* Pending Invitations */} + {teamInvitations + .filter(inv => inv.status === 'PENDING') + .map((invitation) => ( + + + + {invitation.inviteeEmail.split('@')[0]} + + + + + {invitation.inviteeEmail} + + + + + {t('team.members.pending', 'PENDING')} + + + {isTeamLeader && !isPersonalTeam && ( + + handleCancelInvitation(invitation.invitationId, invitation.inviteeEmail)} + aria-label={t('team.invite.cancelLabel', 'Cancel invitation')} + > + + + + )} + + ))} + + )} + +
+
+ +
+ ); +} diff --git a/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx b/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx new file mode 100644 index 000000000..778f030d3 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/SaasPlanSection.tsx @@ -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(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 ( +
+ }> + + {t( + 'settings.planBilling.notAvailable', + 'Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).' + )} + + +
+ ); + } + + // Loading state while checking mode + if (isSaasMode === null) { + return ( +
+ +
+ ); + } + + // Loading state while fetching billing/team data + // Note: loading already includes teamLoading from billing context + if (loading) { + return ( +
+ + + + {t('settings.planBilling.loading', 'Loading billing information...')} + + +
+ ); + } + + // Error state + if (error) { + return ( +
+ } + title={t('settings.planBilling.errors.fetchFailed', 'Unable to fetch billing data')} + > + + {error} + + + +
+ ); + } + + // Main content + return ( + +
+ {/* Header with title and Manage Billing button */} + +

+ {t('settings.planBilling.currentPlan', 'Active Plan')} +

+ {tier !== 'free' && !isManagedTeamMember && ( + + )} +
+ + {/* Trial Status Alert */} + {isTrialing && trialDaysRemaining !== undefined && subscription?.currentPeriodEnd && ( + } + mt="md" + mb="md" + title={t('settings.planBilling.trial.title', 'Free Trial Active')} + > + + {t('settings.planBilling.trial.daysRemainingFull', 'Your trial ends in {{days}} days', { + days: trialDaysRemaining, + defaultValue: `Your trial ends in ${trialDaysRemaining} days`, + })} + + + {t('settings.planBilling.trial.endDate', 'Expires: {{date}}', { + date: formatDate(subscription.currentPeriodEnd), + defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`, + })} + + + )} + + {/* Plan cards */} + + {/* Current subscription card */} + + + {/* Available plans grid */} + + +
+
+ ); +} diff --git a/frontend/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx b/frontend/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx new file mode 100644 index 000000000..32c573f7c --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/plan/ActiveSubscriptionCard.tsx @@ -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 ( + + + + {/* Left side: Name, badges, description */} +
+ + + {!isPersonalTeam && isTeamLeader ? t('settings.planBilling.tier.team', 'Team Plan') : getTierName()} + + {!isPersonalTeam && ( + }> + {t('settings.planBilling.tier.teamBadge', 'Team')} + + )} + + + {t('settings.planBilling.tier.teamTooltipCredits', { + credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, + defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`, + })} + + + {t('settings.planBilling.tier.teamTooltipOverage', { + price: getFormattedOveragePrice(), + defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`, + })} + + + {t('settings.planBilling.tier.teamTooltipFineprint', 'Only pay for what you use beyond included credits.')} + +
+ } + multiline + withArrow + position="right" + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + + + + + {isTrialing && ( + + {t('settings.planBilling.status.trial', 'Trial')} + + )} +
+ {!isPersonalTeam && !isTeamLeader && ( + + {t('settings.planBilling.team.managedByTeam', 'Managed by team')} + + )} + {!isPersonalTeam && isTeamLeader && currentTeam && ( + + {t('settings.planBilling.team.memberCount', '{{count}} team members', { count: currentTeam.seatsUsed })} + + )} + + {getDescription()} + + {/* Show overage cost if applicable */} + {usage && usage.currentPeriodCredits > 0 && ( + + {formatOverageCost(usage.estimatedCost, usage.currentPeriodCredits)} + + )} + + + {/* Right side: Price */} +
+ {!isPersonalTeam && !isTeamLeader ? ( + + {t('settings.planBilling.team.managedByTeam', 'Managed by team')} + + ) : ( + + {getPriceDisplay()} + + )} +
+ + + {/* Next billing date at bottom */} + {subscription?.currentPeriodEnd && ( + + + {t('settings.planBilling.billing.nextBillingDate', 'Next billing date:')} {formatDate(subscription.currentPeriodEnd)} + + + )} +
+
+ ); + } + + // Free plan card + return ( + + +
+ + + {getTierName()} + + + + {getDescription()} + +
+
+ + {getPriceDisplay()} + +
+
+
+ ); +} diff --git a/frontend/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx b/frontend/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx new file mode 100644 index 000000000..88bdad890 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/plan/PlanUpgradeCard.tsx @@ -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 ( + + + {/* Header */} + + {t('settings.planBilling.upgrade.title', 'Upgrade Your Plan')} + + + {/* Team plan benefits */} + + {t('settings.planBilling.upgrade.subtitle', 'Upgrade to Team for:')} + + + + + + } + > + + {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)`, + })} + + {t('settings.planBilling.upgrade.featureMembers', 'Unlimited team members')} + {t('settings.planBilling.upgrade.featureThroughput', 'Faster processing throughput')} + {t('settings.planBilling.upgrade.featureApi', 'API access for automation')} + {t('settings.planBilling.upgrade.featureSupport', 'Priority support')} + + + {/* Upgrade button */} + + + + {t('settings.planBilling.upgrade.opensInBrowser', 'Opens in browser to complete upgrade')} + + + + ); +} diff --git a/frontend/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx b/frontend/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx new file mode 100644 index 000000000..e3d72c449 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/plan/SaaSAvailablePlansSection.tsx @@ -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 = ({ + 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 ( +
+ +
+ ); + } + + if (error) { + return ( + + + {t('plan.availablePlans.loadError', 'Unable to load plan pricing. Using default values.')} + + + ); + } + + return ( +
+ + {t('plan.availablePlans.title', 'Available Plans')} + + + {plans.map(plan => ( + + ))} + +
+ ); +}; diff --git a/frontend/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx b/frontend/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx new file mode 100644 index 000000000..207e4a6f2 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/plan/SaasPlanCard.tsx @@ -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 = ({ + 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) => { + 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) => { + 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 ( + + {plan.popular && ( + + {t('plan.popular', 'Popular')} + + )} + + +
+ {plan.name} + + + {plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`} + + {!plan.isContactOnly && ( + + {plan.period} + + )} + + + {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')}` + } + +
+ + + + {plan.id === 'free' + ? t('credits.modal.forRegularWork', 'For regular PDF work:') + : plan.id === 'enterprise' + ? t('credits.modal.everythingInCredits', 'Everything in Credits, plus:') + : t('credits.modal.everythingInFree', 'Everything in Free, plus:') + } + + {plan.highlights.map((highlight: string, index: number) => ( + + {highlight} + + ))} + + +
+ + + + + ); +}; diff --git a/frontend/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx b/frontend/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx new file mode 100644 index 000000000..4cf1c28bd --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/plan/UsageDisplay.tsx @@ -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 ( + + + {/* Header */} + + {t('settings.planBilling.credits.title', 'Credit Usage')} + + + {/* Monthly credits info */} + + + {t('settings.planBilling.credits.included', { + count: monthlyCredits, + defaultValue: `${monthlyCredits} credits/month (included)`, + })} + + + + {/* Overage credits (if metered billing enabled) */} + {usage && usage.currentPeriodCredits > 0 && ( + <> + + + + {t('settings.planBilling.credits.overage', { + count: usage.currentPeriodCredits, + defaultValue: `+ ${usage.currentPeriodCredits} overage`, + })} + + + {t('settings.planBilling.credits.estimatedCost', { + amount: formatCurrency(usage.estimatedCost), + defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`, + })} + + + + {/* Progress bar for overage usage */} + + + + }> + + {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.`, + })} + + + + )} + + {/* No overage message */} + {(!usage || usage.currentPeriodCredits === 0) && tier !== 'free' && ( + + + {t('settings.planBilling.credits.noOverage', { + count: monthlyCredits, + defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`, + })} + + + )} + + {/* Free tier message */} + {tier === 'free' && ( + + + {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.`, + })} + + + )} + + + ); +} diff --git a/frontend/src/desktop/components/shared/config/types.ts b/frontend/src/desktop/components/shared/config/types.ts index cdbf0558a..4f52474ae 100644 --- a/frontend/src/desktop/components/shared/config/types.ts +++ b/frontend/src/desktop/components/shared/config/types.ts @@ -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]; diff --git a/frontend/src/desktop/components/shared/modals/CreditExhaustedModal.tsx b/frontend/src/desktop/components/shared/modals/CreditExhaustedModal.tsx new file mode 100644 index 000000000..9f4cd08d4 --- /dev/null +++ b/frontend/src/desktop/components/shared/modals/CreditExhaustedModal.tsx @@ -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 ( + + + + {t('credits.modal.managedMemberMessage', 'You have unlimited access to credits through your team. If you need assistance, please contact your team leader.')} + + + + + ); + } + + // 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 ( + + + {t('credits.modal.titleExhaustedPro', 'You have run out of credits')} + + + {t('credits.modal.subtitlePro', 'Enable automatic overage billing to never run out of credits.')} + + + } + 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))', + }, + }} + > + + + + {meteringError && ( + + {meteringError} + + )} + + {/* Explanation Card */} + + + + + + {t('credits.modal.meteringTitle', 'Pay-What-You-Use Overage Billing')} + + + + + {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.`, + })} + + + + + {t('credits.modal.meteringIncluded', { + credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH, + defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`, + })} + + + {t('credits.modal.meteringPrice', 'Additional credits at {{price}}/credit', { + price: formattedOveragePrice, + })} + + + {t('credits.modal.meteringPayAsYouGo', 'Only pay for what you use')} + + + {t('credits.modal.meteringNoCommitment', 'No commitment, cancel anytime')} + + + {t('credits.modal.meteringNeverRunOut', 'Never run out of credits')} + + + + + + {t('credits.modal.meteringBillingNote', 'Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.')} + + + + + + {/* Action Buttons */} + + + {!isTeamLeader && ( + + {t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')} + + )} + + + + + ); + } + + // 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 ( + + + {t('credits.modal.titleExhausted', "You've used your free credits")} + + + {t('credits.modal.subtitle', 'Upgrade to Team for 10x the credits and faster processing.')} + + + } + 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))', + }, + }} + > + + + + + {/* Free Plan Card */} + + +
+ + {t('credits.modal.freeTier', 'Free Tier')} + + + + {currencySymbol}0 + + + {t('credits.modal.perMonth', '/month')} + + + + {BILLING_CONFIG.FREE_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')} + +
+ + + + {t('credits.modal.forRegularWork', 'For regular PDF work:')} + + {FREE_PLAN_FEATURES.map((feature, index) => ( + + {t(feature.translationKey, feature.defaultText)} + + ))} + + + +
+
+ + {/* Team Plan Card */} + 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)'; + }} + > + + {t('credits.modal.popular', 'Popular')} + + +
+ + {t('credits.modal.teamSubscription', 'Team')} + + + + {currencySymbol} + {teamPrice} + + + {t('credits.modal.perMonth', '/month')} + + + + {BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} {t('credits.modal.monthlyCredits', 'monthly credits')} + {formattedOveragePrice}/{t('credits.modal.overage', 'overage')} + +
+ + + + {t('credits.modal.everythingInFree', 'Everything in Free, plus:')} + + {TEAM_PLAN_FEATURES.map((feature, index) => ( + + {t(feature.translationKey, feature.defaultText)} + + ))} + + + +
+
+ + {/* Enterprise Plan Card */} + + +
+ + {t('credits.modal.enterpriseSubscription', 'Enterprise')} + + + {t('credits.modal.customPricing', 'Custom')} + + + {t('credits.modal.unlimitedMonthlyCredits', 'Site License')} + +
+ + + + {t('credits.modal.everythingInCredits', 'Everything in Credits, plus:')} + + {ENTERPRISE_PLAN_FEATURES.map((feature, index) => ( + + {t(feature.translationKey, feature.defaultText)} + + ))} + + + +
+
+
+ + + {t('credits.modal.selfHostPrompt', 'Want to self host?')}{' '} + { + e.currentTarget.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.textDecoration = 'none'; + }} + > + {t('credits.modal.selfHostLink', 'Review the docs and plans')} + + +
+
+ ); +} diff --git a/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx new file mode 100644 index 000000000..994a2b916 --- /dev/null +++ b/frontend/src/desktop/components/shared/modals/CreditModalBootstrap.tsx @@ -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 ( + <> + setExhaustedOpen(false)} + /> + setInsufficientOpen(false)} + toolId={insufficientDetails.toolId} + requiredCredits={insufficientDetails.requiredCredits} + /> + + ); +} diff --git a/frontend/src/desktop/components/shared/modals/CreditUsageBanner.tsx b/frontend/src/desktop/components/shared/modals/CreditUsageBanner.tsx new file mode 100644 index 000000000..04e27a879 --- /dev/null +++ b/frontend/src/desktop/components/shared/modals/CreditUsageBanner.tsx @@ -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 ( + + + + + + {t('credits.modal.creditsThisMonth', 'Monthly credits')} + + + {t('credits.modal.creditsRemaining', '{{current}} of {{total}} remaining', { + current: currentCredits, + total: totalCredits, + })} + + + + + + + ); +} diff --git a/frontend/src/desktop/components/shared/modals/FeatureListItem.tsx b/frontend/src/desktop/components/shared/modals/FeatureListItem.tsx new file mode 100644 index 000000000..fc3f2e474 --- /dev/null +++ b/frontend/src/desktop/components/shared/modals/FeatureListItem.tsx @@ -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 = { + 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 ( + + + + {children} + + + ); +} diff --git a/frontend/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx b/frontend/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx new file mode 100644 index 000000000..1a07b0547 --- /dev/null +++ b/frontend/src/desktop/components/shared/modals/InsufficientCreditsModal.tsx @@ -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 ( + + + + {t('credits.insufficient.title', 'Insufficient Credits')} + + + } + > + + }> + + {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, + } + )} + + + + {isManagedTeamMember ? ( + <> + + {t( + 'credits.insufficient.managedMember', + 'Please contact your team leader for assistance.' + )} + + + + ) : tier === 'team' ? ( + <> + + {t( + 'credits.insufficient.teamMember', + 'Enable overage billing to never run out of credits.' + )} + + {meteringError && ( + + {meteringError} + + )} + + {!isTeamLeader && ( + + {t('credits.modal.teamLeaderOnly', 'Only team leaders can enable overage billing')} + + )} + + + ) : ( + <> + + {t( + 'credits.insufficient.freeTier', + 'Upgrade to Team for 10x more credits and unlimited overage billing.' + )} + + + + + )} + + + ); +} diff --git a/frontend/src/desktop/config/billing.ts b/frontend/src/desktop/config/billing.ts new file mode 100644 index 000000000..f6768a11a --- /dev/null +++ b/frontend/src/desktop/config/billing.ts @@ -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(); +} diff --git a/frontend/src/desktop/config/planFeatures.ts b/frontend/src/desktop/config/planFeatures.ts new file mode 100644 index 000000000..bc747225c --- /dev/null +++ b/frontend/src/desktop/config/planFeatures.ts @@ -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' + } +]; diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index 863bc5140..cc674048d 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -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'); +} diff --git a/frontend/src/desktop/constants/creditEvents.ts b/frontend/src/desktop/constants/creditEvents.ts new file mode 100644 index 000000000..e66896d30 --- /dev/null +++ b/frontend/src/desktop/constants/creditEvents.ts @@ -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]; diff --git a/frontend/src/desktop/contexts/SaaSCheckoutContext.tsx b/frontend/src/desktop/contexts/SaaSCheckoutContext.tsx new file mode 100644 index 000000000..0b0e992d4 --- /dev/null +++ b/frontend/src/desktop/contexts/SaaSCheckoutContext.tsx @@ -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(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 = ({ + children +}) => { + const [opened, setOpened] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(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 ( + + {children} + + + ); +}; diff --git a/frontend/src/desktop/contexts/SaaSTeamContext.tsx b/frontend/src/desktop/contexts/SaaSTeamContext.tsx new file mode 100644 index 000000000..fcef6899e --- /dev/null +++ b/frontend/src/desktop/contexts/SaaSTeamContext.tsx @@ -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; + acceptInvitation: (token: string) => Promise; + rejectInvitation: (token: string) => Promise; + cancelInvitation: (invitationId: number) => Promise; + removeMember: (memberId: number) => Promise; + leaveTeam: () => Promise; + refreshTeams: () => Promise; +} + +const SaaSTeamContext = createContext({ + 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(null); + const [teams, setTeams] = useState([]); + const [teamMembers, setTeamMembers] = useState([]); + const [teamInvitations, setTeamInvitations] = useState([]); + const [receivedInvitations, setReceivedInvitations] = useState([]); + 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('/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(`/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(`/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('/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 ( + + {children} + + ); +} + +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 }; diff --git a/frontend/src/desktop/contexts/SaasBillingContext.tsx b/frontend/src/desktop/contexts/SaasBillingContext.tsx new file mode 100644 index 000000000..890ff2040 --- /dev/null +++ b/frontend/src/desktop/contexts/SaasBillingContext.tsx @@ -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; + plansLoading: boolean; + plansError: string | null; + + // Derived State + isManagedTeamMember: boolean; + + // State Flags + loading: boolean; + error: string | null; + lastFetchTime: number | null; + + // Actions + refreshBilling: () => Promise; + refreshCredits: () => Promise; // Alias for refreshBilling (for clarity) + refreshPlans: () => Promise; + openBillingPortal: () => Promise; +} + +const SaasBillingContext = createContext({ + 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(null); + const [plans, setPlans] = useState>(new Map()); + const [plansLoading, setPlansLoading] = useState(false); + const [plansError, setPlansError] = useState(null); + const [loading, setLoading] = useState(false); // Start false (lazy load) + const [error, setError] = useState(null); + const [lastFetchTime, setLastFetchTime] = useState(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 ( + + {children} + + ); +} + +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 }; diff --git a/frontend/src/desktop/hooks/useConversionCloudStatus.ts b/frontend/src/desktop/hooks/useConversionCloudStatus.ts new file mode 100644 index 000000000..c5ca962ca --- /dev/null +++ b/frontend/src/desktop/hooks/useConversionCloudStatus.ts @@ -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; // Available on local OR SaaS? + cloudStatus: Record; // Will use cloud? + localOnly: Record; // 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({ + 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 = {}; + const cloudStatus: Record = {}; + const localOnly: Record = {}; + + // 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; +} diff --git a/frontend/src/desktop/hooks/useCreditCheck.ts b/frontend/src/desktop/hooks/useCreditCheck.ts new file mode 100644 index 000000000..4a387eaf6 --- /dev/null +++ b/frontend/src/desktop/hooks/useCreditCheck.ts @@ -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 => { + 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 }; +} diff --git a/frontend/src/desktop/hooks/useCreditEvents.ts b/frontend/src/desktop/hooks/useCreditEvents.ts new file mode 100644 index 000000000..c587c3309 --- /dev/null +++ b/frontend/src/desktop/hooks/useCreditEvents.ts @@ -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]); +} diff --git a/frontend/src/desktop/hooks/useEnableMeteredBilling.ts b/frontend/src/desktop/hooks/useEnableMeteredBilling.ts new file mode 100644 index 000000000..97467fca4 --- /dev/null +++ b/frontend/src/desktop/hooks/useEnableMeteredBilling.ts @@ -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, + onSuccess: () => void, + logPrefix: string +): { + enablingMetering: boolean; + meteringError: string | null; + handleEnableMetering: () => Promise; +} { + const [enablingMetering, setEnablingMetering] = useState(false); + const [meteringError, setMeteringError] = useState(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 }; +} diff --git a/frontend/src/desktop/hooks/useEndpointConfig.ts b/frontend/src/desktop/hooks/useEndpointConfig.ts index 0ee870b81..ad140dd4a 100644 --- a/frontend/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/src/desktop/hooks/useEndpointConfig.ts @@ -35,7 +35,8 @@ async function checkDependenciesReady(): Promise { 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; } { const { t } = useTranslation(); - const [enabled, setEnabled] = useState(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(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(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); + // 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, details: {} as Record }); + + // 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 })); } diff --git a/frontend/src/desktop/hooks/useSaaSPlans.ts b/frontend/src/desktop/hooks/useSaaSPlans.ts new file mode 100644 index 000000000..17ac2fc07 --- /dev/null +++ b/frontend/src/desktop/hooks/useSaaSPlans.ts @@ -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(null); + const [dynamicPrices, setDynamicPrices] = useState>(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(() => { + 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, + }; +}; diff --git a/frontend/src/desktop/hooks/useToolCloudStatus.ts b/frontend/src/desktop/hooks/useToolCloudStatus.ts new file mode 100644 index 000000000..fbe0806bb --- /dev/null +++ b/frontend/src/desktop/hooks/useToolCloudStatus.ts @@ -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; +} diff --git a/frontend/src/desktop/hooks/useToolManagement.tsx b/frontend/src/desktop/hooks/useToolManagement.tsx new file mode 100644 index 000000000..2f1d9879e --- /dev/null +++ b/frontend/src/desktop/hooks/useToolManagement.tsx @@ -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>; + +interface ToolManagementResult { + selectedTool: ToolRegistryEntry | null; + toolSelectedFileIds: FileId[]; + toolRegistry: Partial; + 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([]); + // 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 = useMemo(() => { + const availableToolRegistry: Partial = {}; + (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, + }; +}; diff --git a/frontend/src/desktop/hooks/useWillUseCloud.ts b/frontend/src/desktop/hooks/useWillUseCloud.ts new file mode 100644 index 000000000..39d838210 --- /dev/null +++ b/frontend/src/desktop/hooks/useWillUseCloud.ts @@ -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; +} diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index 0a5037b1f..cb923b016 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -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) => { diff --git a/frontend/src/desktop/services/backendReadinessGuard.ts b/frontend/src/desktop/services/backendReadinessGuard.ts index 4c87dd456..1742835d2 100644 --- a/frontend/src/desktop/services/backendReadinessGuard.ts +++ b/frontend/src/desktop/services/backendReadinessGuard.ts @@ -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 { +export async function ensureBackendReady(endpoint?: string): Promise { + // 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; } diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index 360abf513..e0a32383e 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -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'); diff --git a/frontend/src/desktop/services/endpointAvailabilityService.ts b/frontend/src/desktop/services/endpointAvailabilityService.ts new file mode 100644 index 000000000..20bad9a75 --- /dev/null +++ b/frontend/src/desktop/services/endpointAvailabilityService.ts @@ -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 = new Map(); + private localCacheExpiry: Map = new Map(); + private saasCache: Map = new Map(); + private saasCacheExpiry: Map = 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 - true if supported locally, false otherwise + */ + async isEndpointSupportedLocally(endpoint: string, backendUrl: string | null): Promise { + // 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 - true if supported on SaaS, false otherwise + */ + async isEndpointSupportedOnSaaS(endpoint: string): Promise { + // 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 { + 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; +} diff --git a/frontend/src/desktop/services/operationRouter.ts b/frontend/src/desktop/services/operationRouter.ts index 871595956..420f3ddb0 100644 --- a/frontend/src/desktop/services/operationRouter.ts +++ b/frontend/src/desktop/services/operationRouter.ts @@ -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 { - const target = await this.getExecutionTarget(_operation); + async getBaseUrl(operation?: string): Promise { + 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 - true if endpoint should skip backend readiness check + */ + async shouldSkipBackendReadyCheck(endpoint?: string): Promise { + // 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 - true if endpoint will route to SaaS + */ + async willRouteToSaaS(endpoint: string): Promise { + 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 = [ diff --git a/frontend/src/desktop/services/saasBillingService.ts b/frontend/src/desktop/services/saasBillingService.ts new file mode 100644 index 000000000..32585d103 --- /dev/null +++ b/frontend/src/desktop/services/saasBillingService.ts @@ -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 { + 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; + 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 { + // 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 { + // 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('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> { + // 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; + 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(); + 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 { + // 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(); diff --git a/frontend/src/desktop/services/saasErrorInterceptor.ts b/frontend/src/desktop/services/saasErrorInterceptor.ts new file mode 100644 index 000000000..06e5dc963 --- /dev/null +++ b/frontend/src/desktop/services/saasErrorInterceptor.ts @@ -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; +} diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index 7474ae7f5..bf36e5e5b 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -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 = {}; @@ -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; } diff --git a/frontend/src/desktop/types/billing.ts b/frontend/src/desktop/types/billing.ts new file mode 100644 index 000000000..3e67a9b77 --- /dev/null +++ b/frontend/src/desktop/types/billing.ts @@ -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'; diff --git a/frontend/src/desktop/utils/creditCosts.ts b/frontend/src/desktop/utils/creditCosts.ts new file mode 100644 index 000000000..1fb8b0dd3 --- /dev/null +++ b/frontend/src/desktop/utils/creditCosts.ts @@ -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 = { + // 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; +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2560e6f5d..4094d2201 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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]);