mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
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:
@@ -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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
+17
-17
@@ -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>
|
||||
|
||||
+7
-7
@@ -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>
|
||||
|
||||
+15
-12
@@ -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]);
|
||||
|
||||
+27
-37
@@ -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 {
|
||||
|
||||
+16
-20
@@ -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 {
|
||||
|
||||
+28
-25
@@ -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}
|
||||
|
||||
+42
-48
@@ -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 || "£",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-10
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user