Compare commits

...
7 changed files with 81 additions and 79 deletions
@@ -5569,6 +5569,7 @@
},
"payment": {
"preparing": "Preparing your checkout...",
"redirecting": "Redirecting to secure checkout...",
"upgradeTitle": "Upgrade to {{planName}}",
"success": "Payment Successful!",
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
@@ -31,9 +31,9 @@ const AdminPlanSection: React.FC = () => {
// Check if we should use static version
useEffect(() => {
// Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
// Only use static version if Supabase is not configured or there's an error
// Stripe key is not required - hosted checkout works without it
if (!isSupabaseConfigured || error) {
setUseStaticVersion(true);
}
}, [error]);
@@ -101,46 +101,6 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Current Plan Section */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<Stack gap="xs">
<Group gap="sm">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
<Badge color="green" variant="light">
{t('subscription.status.active', 'Active')}
</Badge>
</Group>
{currentLicenseInfo && (
<Text size="sm" c="dimmed">
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
</Text>
)}
</Stack>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
</Text>
</div>
</Group>
</Card>
</div>
{/* Available Plans */}
<div>
@@ -1,8 +1,7 @@
import React, { useEffect } from 'react';
import { Modal, Text, Alert, Stack, Button, Group, ActionIcon } from '@mantine/core';
import { Modal, Text, Group, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { loadStripe } from '@stripe/stripe-js';
import licenseService from '@app/services/licenseService';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
@@ -37,8 +36,6 @@ if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
);
}
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
@@ -192,25 +189,8 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Render stage content
const renderContent = () => {
// Check if Stripe is configured
if (!stripePromise) {
return (
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.stripeNotConfiguredMessage',
'Stripe payment integration is not configured. Please contact your administrator.'
)}
</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
}
// Don't block checkout - hosted mode works without publishable key
// The checkout will automatically redirect to Stripe hosted page if key is missing
switch (checkoutState.state.currentStage) {
case 'email':
return (
@@ -35,10 +35,15 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
}
if (!stripePromise) {
// This should only happen if embedded mode was attempted without key
// Hosted checkout should have redirected before reaching this component
return (
<Text size="sm" c="red">
Stripe is not configured properly.
</Text>
<Stack align="center" gap="md" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.redirecting', 'Redirecting to secure checkout...')}
</Text>
</Stack>
);
}
@@ -1,7 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier, PlanTier } from '@app/services/licenseService';
import { StripeCheckout } from '@app/components/shared/stripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
@@ -54,8 +53,33 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
licenseKey?: string;
} | null>(null);
// Load plans with current currency
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
// Lazy-loaded plans state (no fetch on mount)
const [plans, setPlans] = useState<PlanTier[]>([]);
const [plansLoaded, setPlansLoaded] = useState(false);
const [plansLoading, setPlansLoading] = useState(false);
// Lazy fetch plans only when needed
const fetchPlansIfNeeded = useCallback(async (currency: string) => {
// Don't fetch if already loading
if (plansLoading) return;
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
console.error('Failed to fetch plans:', error);
// Don't block - let components handle the error
} finally {
setPlansLoading(false);
}
}, [plansLoading]);
const refetchPlans = useCallback(() => {
setPlansLoaded(false); // Force refetch
return fetchPlansIfNeeded(currentCurrency);
}, [currentCurrency, fetchPlansIfNeeded]);
// Handle return from hosted Stripe checkout
useEffect(() => {
@@ -89,6 +113,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
if (activation.success) {
console.log('License synced successfully, refreshing license context');
// Ensure plans are loaded before using them
if (!plansLoaded) {
await fetchPlansIfNeeded(currentCurrency);
}
// Refresh global license context
await refetchLicense();
await refetchPlans();
@@ -135,6 +164,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
if (activation.success) {
console.log(`License key activated: ${activation.licenseType}`);
// Ensure plans are loaded before using them
if (!plansLoaded) {
await fetchPlansIfNeeded(currentCurrency);
}
// Refresh global license context
await refetchLicense();
await refetchPlans();
@@ -201,7 +235,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
};
handleCheckoutReturn();
}, [t, refetchPlans, refetchLicense, plans]);
}, [t, refetchPlans, refetchLicense, plans, fetchPlansIfNeeded, plansLoaded, currentCurrency]);
const openCheckout = useCallback(
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
@@ -217,7 +251,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
const currency = options.currency || currentCurrency;
if (currency !== currentCurrency) {
setCurrentCurrency(currency);
// Plans will reload automatically via usePlans
}
// Fetch plans if not already loaded
if (!plansLoaded) {
await fetchPlansIfNeeded(currency);
}
// Fetch license info and user data for seat calculations
@@ -277,7 +315,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
setIsLoading(false);
}
},
[currentCurrency, plans]
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded]
);
const closeCheckout = useCallback(() => {
@@ -3,6 +3,16 @@
* Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP)
*/
/**
* Check if Stripe publishable key is configured
* Similar to isSupabaseConfigured pattern - checks availability at decision points
* @returns true if key exists and has valid format
*/
export function isStripeConfigured(): boolean {
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
return !!stripeKey && stripeKey.startsWith('pk_');
}
/**
* Check if the current context is secure (HTTPS or localhost)
* @returns true if HTTPS or localhost, false if HTTP
@@ -28,16 +38,24 @@ export function isSecureContext(): boolean {
/**
* Get the appropriate Stripe checkout UI mode based on current context
* @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP
* @returns 'embedded' for HTTPS with key, 'hosted' for HTTP or missing key
*/
export function getCheckoutMode(): 'embedded' | 'hosted' {
// Force hosted checkout if no publishable key (regardless of protocol)
// Hosted checkout works without the key - it just redirects to Stripe
if (!isStripeConfigured()) {
return 'hosted';
}
// Normal protocol-based detection if key is available
return isSecureContext() ? 'embedded' : 'hosted';
}
/**
* Check if Embedded Checkout can be used in current context
* @returns true if secure context (HTTPS/localhost)
* Requires both HTTPS and Stripe publishable key
* @returns true if secure context AND key is configured
*/
export function canUseEmbeddedCheckout(): boolean {
return isSecureContext();
return isSecureContext() && isStripeConfigured();
}