Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,38 +1,37 @@
import React, { useEffect } from 'react';
import { Modal, Text, Group, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import licenseService from '@app/services/licenseService';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { StripeCheckoutProps } from '@app/components/shared/stripeCheckout/types/checkout';
import { validateEmail, getModalTitle } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { calculateSavings } from '@app/components/shared/stripeCheckout/utils/savingsCalculator';
import { useCheckoutState } from '@app/components/shared/stripeCheckout/hooks/useCheckoutState';
import { useCheckoutNavigation } from '@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation';
import { useLicensePolling } from '@app/components/shared/stripeCheckout/hooks/useLicensePolling';
import { useCheckoutSession } from '@app/components/shared/stripeCheckout/hooks/useCheckoutSession';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { PlanSelectionStage } from '@app/components/shared/stripeCheckout/stages/PlanSelectionStage';
import { PaymentStage } from '@app/components/shared/stripeCheckout/stages/PaymentStage';
import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/SuccessStage';
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
import React, { useEffect } from "react";
import { Modal, Text, Group, ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import licenseService from "@app/services/licenseService";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { StripeCheckoutProps } from "@app/components/shared/stripeCheckout/types/checkout";
import { validateEmail, getModalTitle } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { calculateSavings } from "@app/components/shared/stripeCheckout/utils/savingsCalculator";
import { useCheckoutState } from "@app/components/shared/stripeCheckout/hooks/useCheckoutState";
import { useCheckoutNavigation } from "@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation";
import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/useLicensePolling";
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
'Please add it to your .env file. ' +
'Get your key from https://dashboard.stripe.com/apikeys'
"VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. " +
"Please add it to your .env file. " +
"Get your key from https://dashboard.stripe.com/apikeys",
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
if (STRIPE_KEY && !STRIPE_KEY.startsWith("pk_")) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
`Invalid Stripe publishable key format. ` + `Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`,
);
}
@@ -57,7 +56,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
checkoutState.state,
checkoutState.setState,
checkoutState.stageHistory,
checkoutState.setStageHistory
checkoutState.setStageHistory,
);
// Initialize license polling hook
@@ -65,7 +64,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
checkoutState.isMountedRef,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
onLicenseActivated
onLicenseActivated,
);
// Initialize checkout session hook
@@ -82,7 +81,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
polling.pollForLicenseKey,
onSuccess,
onError,
onLicenseActivated
onLicenseActivated,
);
// Calculate savings
@@ -92,17 +91,17 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState(prev => ({ ...prev, email: checkoutState.emailInput }));
navigation.goToStage('plan-selection');
checkoutState.setState((prev) => ({ ...prev, email: checkoutState.emailInput }));
navigation.goToStage("plan-selection");
} else {
checkoutState.setEmailError(validation.error);
}
};
// Plan selection handler
const handlePlanSelect = (period: 'monthly' | 'yearly') => {
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage('payment');
navigation.goToStage("payment");
};
// Close handler
@@ -136,19 +135,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Handle hosted checkout success - open directly to success state
if (hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
console.log("Opening modal to success state for hosted checkout return");
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
checkoutState.setCurrentLicenseKey('existing'); // Flag to indicate upgrade
checkoutState.setPollingStatus('ready');
checkoutState.setCurrentLicenseKey("existing"); // Flag to indicate upgrade
checkoutState.setPollingStatus("ready");
} else if (hostedCheckoutSuccess.licenseKey) {
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
checkoutState.setPollingStatus('ready');
checkoutState.setPollingStatus("ready");
}
// Set to success state to show success UI
checkoutState.setState({ currentStage: 'success', loading: false });
checkoutState.setState({ currentStage: "success", loading: false });
return;
}
@@ -157,32 +156,35 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
try {
const licenseInfo = await licenseService.getLicenseInfo();
// Only skip email if license is PRO or ENTERPRISE (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== 'NORMAL') {
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// Has valid premium license - skip email stage
console.log('Valid premium license detected - skipping email stage');
console.log("Valid premium license detected - skipping email stage");
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey || null);
checkoutState.setState({ currentStage: 'plan-selection', loading: false });
checkoutState.setState({ currentStage: "plan-selection", loading: false });
} else {
// No valid premium license - start at email stage
checkoutState.setState({ currentStage: 'email', loading: false });
checkoutState.setState({ currentStage: "email", loading: false });
}
} catch (error) {
console.warn('Could not check for existing license:', error);
console.warn("Could not check for existing license:", error);
// Default to email stage if check fails
checkoutState.setState({ currentStage: 'email', loading: false });
checkoutState.setState({ currentStage: "email", loading: false });
}
};
checkExistingLicense();
}, [opened, hostedCheckoutSuccess, checkoutState.setCurrentLicenseKey, checkoutState.setPollingStatus, checkoutState.setLicenseKey, checkoutState.setState]);
}, [
opened,
hostedCheckoutSuccess,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
checkoutState.setState,
]);
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (
checkoutState.state.currentStage === 'payment' &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
if (checkoutState.state.currentStage === "payment" && !checkoutState.state.clientSecret && !checkoutState.state.loading) {
session.createCheckoutSession();
}
}, [checkoutState.state.currentStage, checkoutState.state.clientSecret, checkoutState.state.loading, session]);
@@ -192,7 +194,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// 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':
case "email":
return (
<EmailStage
emailInput={checkoutState.emailInput}
@@ -202,7 +204,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'plan-selection':
case "plan-selection":
return (
<PlanSelectionStage
planGroup={planGroup}
@@ -212,7 +214,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'payment':
case "payment":
return (
<PaymentStage
clientSecret={checkoutState.state.clientSecret || null}
@@ -221,7 +223,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'success':
case "success":
return (
<SuccessStage
pollingStatus={checkoutState.pollingStatus}
@@ -231,13 +233,8 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'error':
return (
<ErrorStage
error={checkoutState.state.error || 'An unknown error occurred'}
onClose={handleClose}
/>
);
case "error":
return <ErrorStage error={checkoutState.state.error || "An unknown error occurred"} onClose={handleClose} />;
default:
return null;
@@ -253,12 +250,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={navigation.goBack}
aria-label={t('common.back', 'Back')}
>
<ActionIcon variant="subtle" size="lg" onClick={navigation.goBack} aria-label={t("common.back", "Back")}>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
@@ -278,7 +270,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
styles={{
body: {},
content: {
maxHeight: '95vh',
maxHeight: "95vh",
},
}}
>
@@ -1,10 +1,10 @@
import React from 'react';
import { Text, Stack } from '@mantine/core';
import { formatPrice } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { PRICE_FONT_WEIGHT } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import React from "react";
import { Text, Stack } from "@mantine/core";
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { PRICE_FONT_WEIGHT } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface SimplePriceProps {
mode: 'simple';
mode: "simple";
price: number;
currency: string;
period: string;
@@ -12,21 +12,21 @@ interface SimplePriceProps {
}
interface EnterprisePriceProps {
mode: 'enterprise';
mode: "enterprise";
basePrice: number;
seatPrice: number;
totalPrice?: number;
currency: string;
period: 'month' | 'year';
period: "month" | "year";
seatCount?: number;
size?: 'sm' | 'md' | 'lg';
size?: "sm" | "md" | "lg";
}
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
if (props.mode === 'simple') {
const fontSize = props.size || '2.25rem';
if (props.mode === "simple") {
const fontSize = props.size || "2.25rem";
return (
<>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
@@ -40,9 +40,9 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
}
// Enterprise mode
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = 'md' } = props;
const fontSize = size === 'lg' ? '2rem' : size === 'sm' ? 'md' : 'xl';
const totalFontSize = size === 'lg' ? '2rem' : '2rem';
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = "md" } = props;
const fontSize = size === "lg" ? "2rem" : size === "sm" ? "md" : "xl";
const totalFontSize = size === "lg" ? "2rem" : "2rem";
return (
<Stack gap="sm">
@@ -53,7 +53,7 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(basePrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
{" "}
/{period}
</Text>
</Text>
@@ -65,7 +65,7 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(seatPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
{" "}
/seat/{period}
</Text>
</Text>
@@ -78,8 +78,8 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={totalFontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
/{period === 'year' ? 'month' : period}
{" "}
/{period === "year" ? "month" : period}
</Text>
</Text>
</div>
@@ -1,23 +1,23 @@
import React from 'react';
import { Badge } from '@mantine/core';
import React from "react";
import { Badge } from "@mantine/core";
interface PricingBadgeProps {
type: 'current' | 'popular' | 'savings';
type: "current" | "popular" | "savings";
label: string;
savingsPercent?: number;
}
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
const color = type === 'current' || type === 'savings' ? 'green' : 'blue';
const size = type === 'savings' ? 'lg' : 'sm';
const color = type === "current" || type === "savings" ? "green" : "blue";
const size = type === "savings" ? "lg" : "sm";
return (
<Badge
color={color}
variant="filled"
size={size}
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
className={type === 'current' ? 'current-plan-badge' : undefined}
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
className={type === "current" ? "current-plan-badge" : undefined}
>
{label}
</Badge>
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { CheckoutState, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { useCallback } from "react";
import { CheckoutState, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Stage navigation and history management hook
@@ -8,28 +8,31 @@ export const useCheckoutNavigation = (
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
stageHistory: CheckoutStage[],
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>,
) => {
const goToStage = useCallback((nextStage: CheckoutStage) => {
setStageHistory(prev => [...prev, state.currentStage]);
setState(prev => ({ ...prev, currentStage: nextStage }));
}, [state.currentStage, setState, setStageHistory]);
const goToStage = useCallback(
(nextStage: CheckoutStage) => {
setStageHistory((prev) => [...prev, state.currentStage]);
setState((prev) => ({ ...prev, currentStage: nextStage }));
},
[state.currentStage, setState, setStageHistory],
);
const goBack = useCallback(() => {
if (stageHistory.length > 0) {
const previousStage = stageHistory[stageHistory.length - 1];
setStageHistory(prev => prev.slice(0, -1));
setStageHistory((prev) => prev.slice(0, -1));
// Reset payment state when going back from payment stage
if (state.currentStage === 'payment') {
setState(prev => ({
if (state.currentStage === "payment") {
setState((prev) => ({
...prev,
currentStage: previousStage,
clientSecret: undefined,
loading: false
loading: false,
}));
} else {
setState(prev => ({ ...prev, currentStage: previousStage }));
setState((prev) => ({ ...prev, currentStage: previousStage }));
}
}
}, [stageHistory, state.currentStage, setState, setStageHistory]);
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import licenseService, { PlanTier } from '@app/services/licenseService';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { CheckoutState, PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
import { useCallback } from "react";
import licenseService, { PlanTier } from "@app/services/licenseService";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
import { CheckoutState, PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Checkout session creation and payment handling hook
@@ -19,20 +19,20 @@ export const useCheckoutSession = (
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void,
) => {
const createCheckoutSession = useCallback(async () => {
if (!selectedPlan) {
setState({
currentStage: 'error',
error: 'Selected plan period is not available',
currentStage: "error",
error: "Selected plan period is not available",
loading: false,
});
return;
}
try {
setState(prev => ({ ...prev, loading: true }));
setState((prev) => ({ ...prev, loading: true }));
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
@@ -46,13 +46,13 @@ export const useCheckoutSession = (
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo?.licenseType && licenseInfo.licenseType !== 'NORMAL' && licenseInfo.licenseKey) {
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL" && licenseInfo.licenseKey) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
console.log('Found existing valid license for upgrade');
console.log("Found existing valid license for upgrade");
}
} catch (error) {
console.warn('Could not fetch license info, proceeding as new license:', error);
console.warn("Could not fetch license info, proceeding as new license:", error);
}
const response = await licenseService.createCheckoutSession({
@@ -66,49 +66,39 @@ export const useCheckoutSession = (
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log('Redirecting to Stripe hosted checkout:', response.url);
console.log("Redirecting to Stripe hosted checkout:", response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState(prev => ({
setState((prev) => ({
...prev,
clientSecret: response.clientSecret,
sessionId: response.sessionId,
loading: false,
}));
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
setState({
currentStage: 'error',
currentStage: "error",
error: errorMessage,
loading: false,
});
onError?.(errorMessage);
}
}, [
selectedPlan,
state.email,
installationId,
minimumSeats,
setState,
setInstallationId,
setCurrentLicenseKey,
onError
]);
}, [selectedPlan, state.email, installationId, minimumSeats, setState, setInstallationId, setCurrentLicenseKey, onError]);
const handlePaymentComplete = useCallback(async () => {
// Preserve state when changing stage
setState(prev => ({ ...prev, currentStage: 'success' }));
setState((prev) => ({ ...prev, currentStage: "success" }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license with Keygen');
setPollingStatus('polling');
console.log("Upgrade detected - resyncing existing license with Keygen");
setPollingStatus("polling");
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
@@ -117,26 +107,26 @@ export const useCheckoutSession = (
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus('ready');
setPollingStatus("ready");
} else {
console.error('Failed to sync upgraded license:', activation.error);
setPollingStatus('timeout');
console.error("Failed to sync upgraded license:", activation.error);
setPollingStatus("timeout");
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
} else {
// NEW PLAN FLOW: Poll for new license key
console.log('New subscription - polling for license key');
console.log("New subscription - polling for license key");
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
}
}
}, [
@@ -147,7 +137,7 @@ export const useCheckoutSession = (
setPollingStatus,
pollForLicenseKey,
onSuccess,
onLicenseActivated
onLicenseActivated,
]);
return {
@@ -1,50 +1,46 @@
import { useState, useCallback, useRef } from 'react';
import { PlanTierGroup } from '@app/services/licenseService';
import { CheckoutState, PollingStatus, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { useState, useCallback, useRef } from "react";
import { PlanTierGroup } from "@app/services/licenseService";
import { CheckoutState, PollingStatus, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Centralized state management hook for checkout flow
*/
export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [state, setState] = useState<CheckoutState>({
currentStage: 'email',
loading: false
currentStage: "email",
loading: false,
});
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
const [emailInput, setEmailInput] = useState<string>('');
const [emailError, setEmailError] = useState<string>('');
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
planGroup.yearly ? 'yearly' : 'monthly'
);
const [emailInput, setEmailInput] = useState<string>("");
const [emailError, setEmailError] = useState<string>("");
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(planGroup.yearly ? "yearly" : "monthly");
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>('idle');
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
// Refs for polling cleanup
const isMountedRef = useRef(true);
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly'
? planGroup.yearly
: planGroup.monthly;
const selectedPlan = selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const resetState = useCallback(() => {
setState({
currentStage: 'email',
currentStage: "email",
loading: false,
clientSecret: undefined,
sessionId: undefined,
error: undefined
error: undefined,
});
setStageHistory([]);
setEmailInput('');
setEmailError('');
setPollingStatus('idle');
setEmailInput("");
setEmailError("");
setPollingStatus("idle");
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
}, [planGroup]);
return {
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
import { useCallback } from "react";
import { pollLicenseKeyWithBackoff, activateLicenseKey } from "@app/utils/licenseCheckoutUtils";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* License key polling and activation logic hook
@@ -9,33 +9,36 @@ export const useLicensePolling = (
isMountedRef: React.RefObject<boolean>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void,
) => {
const pollForLicenseKey = useCallback(async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
const pollForLicenseKey = useCallback(
async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
onStatusChange: setPollingStatus,
});
if (!activation.success) {
console.error('Failed to activate license key:', activation.error);
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error("Failed to activate license key:", activation.error);
}
} else if (result.timedOut) {
console.warn("License key polling timed out");
} else if (result.error) {
console.error("License key polling failed:", result.error);
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
} else if (result.error) {
console.error('License key polling failed:', result.error);
}
}, [isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated]);
},
[isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated],
);
return { pollForLicenseKey };
};
@@ -1,8 +1,8 @@
export { default as StripeCheckout } from '@app/components/shared/stripeCheckout/StripeCheckout';
export { default as StripeCheckout } from "@app/components/shared/stripeCheckout/StripeCheckout";
export type {
StripeCheckoutProps,
CheckoutStage,
CheckoutState,
PollingStatus,
SavingsCalculation
} from '@app/components/shared/stripeCheckout/types/checkout';
SavingsCalculation,
} from "@app/components/shared/stripeCheckout/types/checkout";
@@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Text, TextInput, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Stack, Text, TextInput, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface EmailStageProps {
emailInput: string;
@@ -9,41 +9,32 @@ interface EmailStageProps {
onSubmit: () => void;
}
export const EmailStage: React.FC<EmailStageProps> = ({
emailInput,
setEmailInput,
emailError,
onSubmit,
}) => {
export const EmailStage: React.FC<EmailStageProps> = ({ emailInput, setEmailInput, emailError, onSubmit }) => {
const { t } = useTranslation();
return (
<Stack gap="lg" style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem 0' }}>
<Stack gap="lg" style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem 0" }}>
<Text size="sm" c="dimmed">
{t('payment.emailStage.description', "We'll use this to send your license key and receipts.")}
{t("payment.emailStage.description", "We'll use this to send your license key and receipts.")}
</Text>
<TextInput
label={t('payment.emailStage.emailLabel', 'Email Address')}
placeholder={t('payment.emailStage.emailPlaceholder', '[email protected]')}
label={t("payment.emailStage.emailLabel", "Email Address")}
placeholder={t("payment.emailStage.emailPlaceholder", "[email protected]")}
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
error={emailError}
size="lg"
required
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
<Button
size="lg"
onClick={onSubmit}
disabled={!emailInput.trim()}
>
{t('payment.emailStage.continue', 'Continue')}
<Button size="lg" onClick={onSubmit} disabled={!emailInput.trim()}>
{t("payment.emailStage.continue", "Continue")}
</Button>
</Stack>
);
@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Alert, Stack, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ErrorStageProps {
error: string;
@@ -11,11 +11,11 @@ export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{error}</Text>
<Button variant="outline" onClick={onClose}>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -1,9 +1,9 @@
import React from 'react';
import { Stack, Text, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { PlanTier } from '@app/services/licenseService';
import React from "react";
import { Stack, Text, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
@@ -15,20 +15,16 @@ interface PaymentStageProps {
onPaymentComplete: () => void;
}
export const PaymentStage: React.FC<PaymentStageProps> = ({
clientSecret,
selectedPlan,
onPaymentComplete,
}) => {
export const PaymentStage: React.FC<PaymentStageProps> = ({ clientSecret, selectedPlan, onPaymentComplete }) => {
const { t } = useTranslation();
// Show loading while creating checkout session
if (!clientSecret || !selectedPlan) {
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</Stack>
);
@@ -38,10 +34,10 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
// This should only happen if embedded mode was attempted without key
// Hosted checkout should have redirected before reaching this component
return (
<Stack align="center" gap="md" style={{ padding: '2rem 0' }}>
<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...')}
{t("payment.redirecting", "Redirecting to secure checkout...")}
</Text>
</Stack>
);
@@ -49,7 +45,6 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
return (
<Stack gap="md">
{/* Stripe Embedded Checkout */}
<EmbeddedCheckoutProvider
key={clientSecret}
@@ -1,47 +1,39 @@
import React from 'react';
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { formatPrice, calculateMonthlyEquivalent, calculateTotalWithSeats } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import React from "react";
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import {
formatPrice,
calculateMonthlyEquivalent,
calculateTotalWithSeats,
} from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
onSelectPlan: (period: 'monthly' | 'yearly') => void;
onSelectPlan: (period: "monthly" | "yearly") => void;
}
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
onSelectPlan,
}) => {
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGroup, minimumSeats, savings, onSelectPlan }) => {
const { t } = useTranslation();
const isEnterprise = planGroup.tier === 'enterprise';
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => onSelectPlan('monthly')}
>
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Paper withBorder p="xl" radius="md" style={getClickablePaperStyle()} onClick={() => onSelectPlan("monthly")}>
<Stack gap="md" style={{ height: "100%" }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
{t("payment.monthly", "Monthly")}
</Text>
<Divider />
@@ -61,15 +53,15 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || '£'}
period={t('payment.perMonth', '/month')}
currency={planGroup.monthly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="light" fullWidth size="lg">
{t('payment.planStage.selectMonthly', 'Select Monthly')}
{t("payment.planStage.selectMonthly", "Select Monthly")}
</Button>
</div>
</Stack>
@@ -85,18 +77,18 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan('yearly')}
onClick={() => onSelectPlan("yearly")}
>
{savings && (
<PricingBadge
type="savings"
label={t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
label={t("payment.planStage.savePercent", "Save {{percent}}%", { percent: savings.percent })}
/>
)}
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Stack gap="md" style={{ height: "100%" }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
{t("payment.yearly", "Yearly")}
</Text>
<Divider />
@@ -108,7 +100,7 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
basePrice={planGroup.yearly.price}
seatPrice={planGroup.yearly.seatPrice}
totalPrice={calculateMonthlyEquivalent(
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount)
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount),
)}
currency={planGroup.yearly.currency}
period="year"
@@ -116,9 +108,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
size="sm"
/>
<Text size="sm" c="dimmed">
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
{t("payment.planStage.billedYearly", "Billed yearly at {{currency}}{{amount}}", {
currency: planGroup.yearly.currency,
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2)
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(
2,
),
})}
</Text>
</Stack>
@@ -127,14 +121,14 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<PriceDisplay
mode="simple"
price={calculateMonthlyEquivalent(planGroup.yearly?.price || 0)}
currency={planGroup.yearly?.currency || '£'}
period={t('payment.perMonth', '/month')}
currency={planGroup.yearly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
<Text size="sm" c="dimmed" mt="xs">
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
{t("payment.planStage.billedYearly", "Billed yearly at {{currency}}{{amount}}", {
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2)
amount: planGroup.yearly?.price.toFixed(2),
})}
</Text>
</Stack>
@@ -143,16 +137,16 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
{savings && (
<Alert color="green" variant="light" p="sm">
<Text size="sm" fw={600}>
{t('payment.planStage.savingsAmount', 'You save {{amount}}', {
amount: formatPrice(savings.amount, savings.currency)
{t("payment.planStage.savingsAmount", "You save {{amount}}", {
amount: formatPrice(savings.amount, savings.currency),
})}
</Text>
</Alert>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="filled" fullWidth size="lg">
{t('payment.planStage.selectYearly', 'Select Yearly')}
{t("payment.planStage.selectYearly", "Select Yearly")}
</Button>
</div>
</Stack>
@@ -1,7 +1,7 @@
import React from 'react';
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
import React from "react";
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
interface SuccessStageProps {
pollingStatus: PollingStatus;
@@ -10,90 +10,76 @@ interface SuccessStageProps {
onClose: () => void;
}
export const SuccessStage: React.FC<SuccessStageProps> = ({
pollingStatus,
currentLicenseKey,
licenseKey,
onClose,
}) => {
export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, currentLicenseKey, licenseKey, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.successMessage',
'Your subscription has been activated successfully.'
)}
</Text>
<Text size="sm">{t("payment.successMessage", "Your subscription has been activated successfully.")}</Text>
{/* License Key Polling Status */}
{pollingStatus === 'polling' && (
{pollingStatus === "polling" && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t('payment.syncingLicense', 'Syncing your upgraded license...')
: t('payment.generatingLicense', 'Generating your license key...')}
? t("payment.syncingLicense", "Syncing your upgraded license...")
: t("payment.generatingLicense", "Generating your license key...")}
</Text>
</Group>
)}
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
{pollingStatus === "ready" && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('payment.licenseKey', 'Your License Key')}
{t("payment.licenseKey", "Your License Key")}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t('common.copy', 'Copy to Clipboard')}
<Button variant="light" size="sm" onClick={() => navigator.clipboard.writeText(licenseKey)}>
{t("common.copy", "Copy to Clipboard")}
</Button>
<Text size="xs" c="dimmed">
{t(
'payment.licenseInstructions',
'This has been added to your installation. You will receive a copy in your email as well.'
"payment.licenseInstructions",
"This has been added to your installation. You will receive a copy in your email as well.",
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === 'ready' && currentLicenseKey && (
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
{pollingStatus === "ready" && currentLicenseKey && (
<Alert color="green" title={t("payment.upgradeComplete", "Upgrade Complete")}>
<Text size="sm">
{t(
'payment.upgradeCompleteMessage',
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
"payment.upgradeCompleteMessage",
"Your subscription has been upgraded successfully. Your existing license key has been updated.",
)}
</Text>
</Alert>
)}
{pollingStatus === 'timeout' && (
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
{pollingStatus === "timeout" && (
<Alert color="yellow" title={t("payment.licenseDelayed", "License Key Processing")}>
<Text size="sm">
{t(
'payment.licenseDelayedMessage',
'Your license key is being generated. Please check your email shortly or contact support.'
"payment.licenseDelayedMessage",
"Your license key is being generated. Please check your email shortly or contact support.",
)}
</Text>
</Alert>
)}
{pollingStatus === 'ready' && (
{pollingStatus === "ready" && (
<Text size="xs" c="dimmed">
{t('payment.canCloseWindow', 'You can now close this window.')}
{t("payment.canCloseWindow", "You can now close this window.")}
</Text>
)}
<Button onClick={onClose} mt="md">
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -1,4 +1,4 @@
import { PlanTierGroup } from '@app/services/licenseService';
import { PlanTierGroup } from "@app/services/licenseService";
export interface StripeCheckoutProps {
opened: boolean;
@@ -7,14 +7,14 @@ export interface StripeCheckoutProps {
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
export type CheckoutStage = 'email' | 'plan-selection' | 'payment' | 'success' | 'error';
export type CheckoutStage = "email" | "plan-selection" | "payment" | "success" | "error";
export type CheckoutState = {
currentStage: CheckoutStage;
@@ -25,7 +25,7 @@ export type CheckoutState = {
loading?: boolean;
};
export type PollingStatus = 'idle' | 'polling' | 'ready' | 'timeout';
export type PollingStatus = "idle" | "polling" | "ready" | "timeout";
export interface SavingsCalculation {
amount: number;
@@ -1,10 +1,10 @@
import { CSSProperties } from 'react';
import { CSSProperties } from "react";
/**
* Shared styling utilities for plan cards
*/
export const CARD_MIN_HEIGHT = '400px';
export const CARD_MIN_HEIGHT = "400px";
export const PRICE_FONT_WEIGHT = 600;
/**
@@ -12,8 +12,8 @@ export const PRICE_FONT_WEIGHT = 600;
*/
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
return {
borderColor: isHighlighted ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isHighlighted ? '2px' : undefined,
borderColor: isHighlighted ? "var(--mantine-color-green-6)" : undefined,
borderWidth: isHighlighted ? "2px" : undefined,
};
}
@@ -22,9 +22,9 @@ export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
*/
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
return {
position: 'relative',
display: 'flex',
flexDirection: 'column',
position: "relative",
display: "flex",
flexDirection: "column",
minHeight: CARD_MIN_HEIGHT,
...getCardBorderStyle(isHighlighted),
};
@@ -35,10 +35,10 @@ export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties
*/
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
return {
cursor: 'pointer',
transition: 'all 0.2s',
height: '100%',
position: 'relative',
cursor: "pointer",
transition: "all 0.2s",
height: "100%",
position: "relative",
...getCardBorderStyle(isHighlighted),
};
}
@@ -1,5 +1,5 @@
import { TFunction } from 'i18next';
import { CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { TFunction } from "i18next";
import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Validate email address format
@@ -9,32 +9,28 @@ export const validateEmail = (email: string): { valid: boolean; error: string }
if (!emailRegex.test(email)) {
return {
valid: false,
error: 'Please enter a valid email address'
error: "Please enter a valid email address",
};
}
return { valid: true, error: '' };
return { valid: true, error: "" };
};
/**
* Get dynamic modal title based on current stage
*/
export const getModalTitle = (
stage: CheckoutStage,
planName: string,
t: TFunction
): string => {
export const getModalTitle = (stage: CheckoutStage, planName: string, t: TFunction): string => {
switch (stage) {
case 'email':
return t('payment.emailStage.modalTitle', 'Get Started - {{planName}}', { planName });
case 'plan-selection':
return t('payment.planStage.modalTitle', 'Select Billing Period - {{planName}}', { planName });
case 'payment':
return t('payment.paymentStage.modalTitle', 'Complete Payment - {{planName}}', { planName });
case 'success':
return t('payment.success', 'Payment Successful!');
case 'error':
return t('payment.error', 'Payment Error');
case "email":
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", { planName });
case "plan-selection":
return t("payment.planStage.modalTitle", "Select Billing Period - {{planName}}", { planName });
case "payment":
return t("payment.paymentStage.modalTitle", "Complete Payment - {{planName}}", { planName });
case "success":
return t("payment.success", "Payment Successful!");
case "error":
return t("payment.error", "Payment Error");
default:
return t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName });
return t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName });
}
};
@@ -18,11 +18,7 @@ export function calculateMonthlyEquivalent(yearlyPrice: number): number {
/**
* Calculate total price including seats
*/
export function calculateTotalWithSeats(
basePrice: number,
seatPrice: number | undefined,
seatCount: number
): number {
export function calculateTotalWithSeats(basePrice: number, seatPrice: number | undefined, seatCount: number): number {
if (seatPrice === undefined) return basePrice;
return basePrice + seatPrice * seatCount;
}
@@ -40,14 +36,14 @@ export function formatPrice(amount: number, currency: string, decimals: number =
*/
export function calculateDisplayPricing(
monthly?: { price: number; seatPrice?: number; currency: string },
yearly?: { price: number; seatPrice?: number; currency: string }
yearly?: { price: number; seatPrice?: number; currency: string },
): PriceCalculation {
// Default to monthly if no yearly exists
if (!yearly) {
return {
displayPrice: monthly?.price || 0,
displaySeatPrice: monthly?.seatPrice,
displayCurrency: monthly?.currency || '£',
displayCurrency: monthly?.currency || "£",
};
}
@@ -1,17 +1,14 @@
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Calculate savings for yearly vs monthly plans
* Returns null if both monthly and yearly plans are not available
*/
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number
): SavingsCalculation | null => {
export const calculateSavings = (planGroup: PlanTierGroup, minimumSeats: number): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === 'enterprise';
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
let monthlyAnnual: number;
@@ -19,8 +16,8 @@ export const calculateSavings = (
if (isEnterprise && planGroup.monthly.seatPrice && planGroup.yearly.seatPrice) {
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
monthlyAnnual = (planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)) * 12;
yearlyTotal = planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount);
monthlyAnnual = (planGroup.monthly.price + planGroup.monthly.seatPrice * seatCount) * 12;
yearlyTotal = planGroup.yearly.price + planGroup.yearly.seatPrice * seatCount;
} else {
// Server: price * 12 vs yearly price
monthlyAnnual = planGroup.monthly.price * 12;
@@ -33,6 +30,6 @@ export const calculateSavings = (
return {
amount: savings,
percent: savingsPercent,
currency: planGroup.yearly.currency
currency: planGroup.yearly.currency,
};
};