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