V2 Payment Features (#4974)

* Added ability to add seats to enterprise
* first logged in date on people page
* Remove Premium config section				
* Cleanup add seat flow					
* Shrink numbers in plan 					
* Make editing text a server feature in the highlights
* default to dollar pricing				
* clear checkout logic when crash				
* Recongnise location and find pricing			
* Payment successful page

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-11-24 16:38:07 +00:00
committed by GitHub
co-authored by Connor Yoh
parent 050408639b
commit 5d18184e46
37 changed files with 2321 additions and 685 deletions
@@ -2,16 +2,17 @@ import React, { createContext, useContext, useState, useCallback, useEffect, Rea
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import { StripeCheckout } from '@app/components/shared/stripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { useLicense } from '@app/contexts/LicenseContext';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency } from '@app/utils/currencyDetection';
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (defaults to 'gbp')
currency?: string; // Optional currency override (auto-detected from locale)
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
@@ -35,15 +36,18 @@ interface CheckoutProviderProps {
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency = 'gbp'
defaultCurrency
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentCurrency, setCurrentCurrency] = useState(() => {
// Use provided default or auto-detect from locale
return defaultCurrency || getPreferredCurrency(i18n.language);
});
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
isUpgrade: boolean;
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
@@ -17,18 +17,45 @@ interface LicenseProviderProps {
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const configRef = useRef(config);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Keep ref updated with latest config
useEffect(() => {
configRef.current = config;
}, [config]);
const refetchLicense = useCallback(async () => {
// Wait for config to load if it's not available yet
let currentConfig = configRef.current;
if (!currentConfig) {
console.log('[LicenseContext] Config not loaded yet, waiting...');
// Wait up to 5 seconds for config to load
const maxWait = 5000;
const startTime = Date.now();
while (!configRef.current && Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 100));
currentConfig = configRef.current;
}
if (!currentConfig) {
console.error('[LicenseContext] Config failed to load after waiting');
setLoading(false);
return;
}
}
// Only fetch license info if user is an admin
if (!config?.isAdmin) {
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
if (!currentConfig.isAdmin) {
console.log('[LicenseContext] User is not an admin, skipping license fetch');
setLoading(false);
return;
}
console.log('[LicenseContext] Fetching license info');
try {
setLoading(true);
setError(null);
@@ -42,7 +69,7 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
} finally {
setLoading(false);
}
}, [config?.isAdmin]);
}, []);
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
@@ -51,12 +78,15 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
}
}, [config, refetchLicense]);
const contextValue: LicenseContextValue = {
licenseInfo,
loading,
error,
refetchLicense,
};
const contextValue: LicenseContextValue = useMemo(
() => ({
licenseInfo,
loading,
error,
refetchLicense,
}),
[licenseInfo, loading, error, refetchLicense]
);
return (
<LicenseContext.Provider value={contextValue}>
@@ -0,0 +1,222 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import licenseService, {} from '@app/services/licenseService';
import UpdateSeatsModal from '@app/components/shared/UpdateSeatsModal';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { useLicense } from '@app/contexts/LicenseContext';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
export interface UpdateSeatsOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
}
interface UpdateSeatsContextValue {
openUpdateSeats: (options?: UpdateSeatsOptions) => Promise<void>;
closeUpdateSeats: () => void;
isOpen: boolean;
isLoading: boolean;
}
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(undefined);
interface UpdateSeatsProviderProps {
children: ReactNode;
}
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentSeats, setCurrentSeats] = useState<number>(1);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentOptions, setCurrentOptions] = useState<UpdateSeatsOptions>({});
// Handle return from Stripe billing portal
useEffect(() => {
const handleBillingReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const seatsUpdated = urlParams.get('seats_updated');
if (seatsUpdated === 'true') {
console.log('Seats updated successfully, syncing license with Keygen');
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
try {
// Wait a moment for Stripe webhook to process
await new Promise(resolve => setTimeout(resolve, 2000));
// Resync license with Keygen (not just local fetch)
console.log('Seat update detected - resyncing license with Keygen');
const activation = await resyncExistingLicense();
if (activation.success) {
console.log('License synced successfully after seat update');
// Refresh global license context
await refetchLicense();
// Get updated license info for notification
const updatedLicense = await licenseService.getLicenseInfo();
alert({
alertType: 'success',
title: t('billing.seatsUpdated', 'Seats Updated'),
body: t(
'billing.seatsUpdatedMessage',
'Your enterprise seats have been updated to {{seats}}',
{ seats: updatedLicense.maxUsers }
),
});
} else {
throw new Error(activation.error || 'Failed to sync license');
}
} catch (error) {
console.error('Failed to sync license after seat update:', error);
alert({
alertType: 'warning',
title: t('billing.updateProcessing', 'Update Processing'),
body: t(
'billing.updateProcessingMessage',
'Your seat update is being processed. Please refresh in a few moments.'
),
});
}
}
};
handleBillingReturn();
}, [t, refetchLicense]);
const openUpdateSeats = useCallback(async (options: UpdateSeatsOptions = {}) => {
try {
setIsLoading(true);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers(),
]);
// Validate this is an enterprise license
if (!licenseInfo || licenseInfo.licenseType !== 'ENTERPRISE') {
throw new Error(
t('billing.notEnterprise', 'Seat management is only available for enterprise licenses')
);
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
const currentUserCount = userData.totalUsers || 0;
// Minimum seats must be at least the current number of users
const calculatedMinSeats = Math.max(currentUserCount, 1);
console.log(
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`
);
setCurrentSeats(currentLicenseSeats);
setMinimumSeats(calculatedMinSeats);
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to open seat update';
console.error('Error opening seat update:', errorMessage);
alert({
alertType: 'error',
title: t('common.error', 'Error'),
body: errorMessage,
});
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
}, [t]);
const closeUpdateSeats = useCallback(() => {
setIsOpen(false);
setCurrentOptions({});
// Refetch license after modal closes to update UI
refetchLicense();
}, [refetchLicense]);
const handleUpdateSeats = useCallback(
async (newSeatCount: number): Promise<string> => {
try {
// Get current license key
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo?.licenseKey) {
throw new Error('No license key found');
}
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
// Call manage-billing function with new seat count
const portalUrl = await licenseService.updateEnterpriseSeats(
newSeatCount,
licenseInfo.licenseKey
);
return portalUrl;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to update seats';
console.error('Error updating seats:', errorMessage);
currentOptions.onError?.(errorMessage);
throw err;
}
},
[currentSeats, currentOptions]
);
const handleSuccess = useCallback(() => {
console.log('Seat update initiated successfully');
currentOptions.onSuccess?.();
}, [currentOptions]);
const handleError = useCallback(
(error: string) => {
console.error('Seat update error:', error);
currentOptions.onError?.(error);
},
[currentOptions]
);
return (
<UpdateSeatsContext.Provider
value={{
openUpdateSeats,
closeUpdateSeats,
isOpen,
isLoading,
}}
>
{children}
<UpdateSeatsModal
opened={isOpen}
onClose={closeUpdateSeats}
currentSeats={currentSeats}
minimumSeats={minimumSeats}
_onSuccess={handleSuccess}
onError={handleError}
onUpdateSeats={handleUpdateSeats}
/>
</UpdateSeatsContext.Provider>
);
};
export const useUpdateSeats = (): UpdateSeatsContextValue => {
const context = useContext(UpdateSeatsContext);
if (!context) {
throw new Error('useUpdateSeats must be used within an UpdateSeatsProvider');
}
return context;
};
export default UpdateSeatsContext;