init stripe

This commit is contained in:
Anthony Stirling 2025-11-10 15:28:47 +00:00
parent ebf4bab80b
commit b47a31c34f
13 changed files with 1336 additions and 42 deletions

View File

@ -299,6 +299,16 @@ public class AdminSettingsController {
+ String.join(", ", VALID_SECTION_NAMES));
}
// Auto-enable premium features if license key is provided
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
Object keyValue = sectionData.get("key");
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
// Automatically set enabled to true when a key is provided
sectionData.put("enabled", true);
log.info("Auto-enabling premium features because license key was provided");
}
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
String propertyKey = entry.getKey();

View File

@ -38,6 +38,8 @@
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
@ -441,7 +443,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -488,7 +489,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -512,7 +512,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@ -596,7 +595,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -613,7 +611,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -631,7 +628,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -668,7 +664,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -703,7 +698,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -740,7 +734,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -816,7 +809,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -972,7 +964,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1016,7 +1007,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2047,7 +2037,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2098,7 +2087,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2166,7 +2154,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@ -3077,6 +3064,29 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
@ -3850,7 +3860,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4174,7 +4183,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4185,7 +4193,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4246,7 +4253,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -4960,6 +4966,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@ -4969,6 +4976,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@ -4979,6 +4987,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@ -4991,6 +5000,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@ -5017,7 +5027,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5702,7 +5711,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -6748,8 +6756,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7144,7 +7151,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7315,7 +7321,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -8638,7 +8643,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9446,7 +9450,6 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@ -11223,7 +11226,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11503,7 +11505,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -11876,7 +11877,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -11886,7 +11886,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13557,7 +13556,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -13859,7 +13857,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -13942,7 +13939,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14147,7 +14143,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14299,7 +14294,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14313,7 +14307,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",

View File

@ -33,6 +33,8 @@
"@mantine/dates": "^8.3.1",
"@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",

View File

@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService from '@app/services/licenseService';
import { alert } from '@app/components/toast';
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
const response = await licenseService.createBillingPortalSession(returnUrl);
window.location.href = response.url;
} catch (error) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t('billing.manageBilling', 'Manage Billing')}
</Button>
);
};

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import licenseService from '@app/services/licenseService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
// Initialize Stripe - this should come from environment variables
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planId: string;
planName: string;
planPrice: number;
currency: string;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionId?: string;
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planId,
planName,
planPrice,
currency,
onSuccess,
onError,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const createCheckoutSession = async () => {
try {
setState({ status: 'loading' });
const response = await licenseService.createCheckoutSession({
planId,
currency,
successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/settings/adminPlan`,
});
setState({
status: 'ready',
clientSecret: response.clientSecret,
sessionId: response.sessionId,
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handlePaymentComplete = () => {
setState({ status: 'success' });
onSuccess?.(state.sessionId || '');
};
const handleClose = () => {
setState({ status: 'idle' });
onClose();
};
// Initialize checkout when modal opens
useEffect(() => {
if (opened && state.status === 'idle') {
createCheckoutSession();
} else if (!opened) {
setState({ status: 'idle' });
}
}, [opened]);
const renderContent = () => {
switch (state.status) {
case 'loading':
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</div>
);
case 'ready':
if (!state.clientSecret) return null;
return (
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
case 'success':
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.successMessage',
'Your subscription has been activated successfully. You will receive a confirmation email shortly.'
)}
</Text>
<Text size="xs" c="dimmed">
{t('payment.autoClose', 'This window will close automatically...')}
</Text>
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
</Text>
<Text size="sm" c="dimmed">
{currency}
{planPrice}/{t('plan.period.month', 'month')}
</Text>
</div>
}
size="xl"
centered
withCloseButton={state.status !== 'ready'}
closeOnEscape={state.status !== 'ready'}
closeOnClickOutside={state.status !== 'ready'}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;

View File

@ -9,11 +9,11 @@ import AdminPrivacySection from '@app/components/shared/config/configSections/Ad
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
export interface ConfigNavItem {
key: NavKey;
@ -126,10 +126,10 @@ export const createConfigNavSections = (
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
key: 'adminPlan',
label: 'Plan',
icon: 'receipt-long-rounded',
component: <AdminPlanSection />
},
{
key: 'adminAudit',

View File

@ -0,0 +1,287 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import { PlanTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import AvailablePlansSection from './plan/AvailablePlansSection';
import ActivePlanSection from './plan/ActivePlanSection';
import StaticPlanSection from './plan/StaticPlanSection';
import { userManagementService } from '@app/services/userManagementService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [currency, setCurrency] = useState<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<any>(null);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
// Check if we should use static version and fetch license info
useEffect(() => {
const fetchLicenseInfo = async () => {
try {
const adminData = await userManagementService.getUsers();
// Determine plan name based on config flags
let planName = 'Free';
if (config?.runningEE) {
planName = 'Enterprise';
} else if (config?.runningProOrHigher || adminData.premiumEnabled) {
planName = 'Pro';
}
setCurrentLicenseInfo({
planName,
maxUsers: adminData.maxAllowedUsers,
grandfathered: adminData.grandfatheredUserCount > 0,
});
} catch (err) {
console.error('Failed to fetch license info:', err);
}
};
// Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || error) {
setUseStaticVersion(true);
fetchLicenseInfo();
}
// Fetch premium settings
fetchPremiumSettings();
}, [error, config]);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
];
const handleUpgradeClick = useCallback(
(plan: PlanTier) => {
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open('https://www.stirling.com/contact', '_blank');
return;
}
if (!currentSubscription || plan.id !== currentSubscription.plan.id) {
setSelectedPlan(plan);
setCheckoutOpen(true);
}
},
[currentSubscription]
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
// Refetch plans to update current subscription
refetch();
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
}, 2000);
},
[refetch]
);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
}, []);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
}
// Early returns after all hooks are called
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
</div>
);
}
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
}
if (!plans || !currentSubscription) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
/>
</Group>
</div>
<ActivePlanSection subscription={currentSubscription} />
<Divider />
<AvailablePlansSection
plans={plans}
currentPlanId={currentSubscription.plan.id}
onUpgradeClick={handleUpgradeClick}
/>
{/* Stripe Checkout Modal */}
{selectedPlan && (
<StripeCheckout
opened={checkoutOpen}
onClose={handleCheckoutClose}
planId={selectedPlan.id}
planName={selectedPlan.name}
planPrice={selectedPlan.price}
currency={selectedPlan.currency}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
/>
)}
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
};
export default AdminPlanSection;

View File

@ -0,0 +1,89 @@
import React from 'react';
import { Card, Text, Group, Stack, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SubscriptionInfo } from '@app/services/licenseService';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
interface ActivePlanSectionProps {
subscription: SubscriptionInfo;
}
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({ subscription }) => {
const { t } = useTranslation();
const getStatusBadge = (status: string) => {
const statusConfig: Record<
string,
{ color: string; label: string }
> = {
active: { color: 'green', label: t('subscription.status.active', 'Active') },
past_due: { color: 'yellow', label: t('subscription.status.pastDue', 'Past Due') },
canceled: { color: 'red', label: t('subscription.status.canceled', 'Canceled') },
incomplete: { color: 'orange', label: t('subscription.status.incomplete', 'Incomplete') },
trialing: { color: 'blue', label: t('subscription.status.trialing', 'Trial') },
none: { color: 'gray', label: t('subscription.status.none', 'No Subscription') },
};
const config = statusConfig[status] || statusConfig.none;
return (
<Badge color={config.color} variant="light">
{config.label}
</Badge>
);
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
{subscription.status !== 'none' && subscription.stripeCustomerId && (
<ManageBillingButton returnUrl={`${window.location.origin}/settings/adminPlan`} />
)}
</div>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<Stack gap="xs">
<Group gap="sm">
<Text size="lg" fw={600}>
{subscription.plan.name}
</Text>
{getStatusBadge(subscription.status)}
</Group>
{subscription.currentPeriodEnd && subscription.status === 'active' && (
<Text size="sm" c="dimmed">
{subscription.cancelAtPeriodEnd
? t('subscription.cancelsOn', 'Cancels on {{date}}', {
date: new Date(subscription.currentPeriodEnd).toLocaleDateString(),
})
: t('subscription.renewsOn', 'Renews on {{date}}', {
date: new Date(subscription.currentPeriodEnd).toLocaleDateString(),
})}
</Text>
)}
</Stack>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{subscription.plan.currency}
{subscription.plan.price}
/month
</Text>
</div>
</Group>
</Card>
</div>
);
};
export default ActivePlanSection;

View File

@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/services/licenseService';
import PlanCard from './PlanCard';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId: string;
onUpgradeClick: (plan: PlanTier) => void;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlanId,
onUpgradeClick,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '1rem',
}}
>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentPlanId}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div style={{ textAlign: 'center' }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
</Text>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>
{t('plan.feature.title', 'Feature')}
</th>
{plans.map((plan) => (
<th
key={plan.id}
style={{ textAlign: 'center', padding: '0.5rem', minWidth: '6rem', position: 'relative' }}
>
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
style={{
position: 'absolute',
top: '0rem',
right: '-2rem',
fontSize: '0.5rem',
fontWeight: '500',
height: '1rem',
padding: '0 0.25rem',
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<td style={{ padding: '0.5rem' }}>{plans[0].features[featureIndex].name}</td>
{plans.map((plan) => (
<td key={plan.id} style={{ textAlign: 'center', padding: '0.5rem' }}>
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}>
</Text>
) : (
<Text c="gray">-</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</Collapse>
</div>
);
};
export default AvailablePlansSection;

View File

@ -0,0 +1,83 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/services/licenseService';
interface PlanCardProps {
plan: PlanTier;
isCurrentPlan: boolean;
onUpgradeClick: (plan: PlanTier) => void;
}
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrentPlan, onUpgradeClick }) => {
const { t } = useTranslation();
return (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{plan.popular && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.isContactOnly
? t('plan.customPricing', 'Custom')
: `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button
variant={isCurrentPlan ? 'filled' : plan.isContactOnly ? 'outline' : 'filled'}
disabled={isCurrentPlan}
fullWidth
onClick={() => onUpgradeClick(plan)}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
: plan.isContactOnly
? t('plan.contact', 'Contact Us')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Stack>
</Card>
);
};
export default PlanCard;

View File

@ -0,0 +1,344 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { alert } from '@app/components/toast';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
interface StaticPlanSectionProps {
currentLicenseInfo?: {
planName: string;
maxUsers: number;
grandfathered: boolean;
};
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const [showLicenseKey, setShowLicenseKey] = useState(false);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const staticPlans = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
price: 0,
currency: '£',
period: t('plan.period.month', '/month'),
highlights: [
t('plan.free.highlight1', 'Limited Tool Usage Per week'),
t('plan.free.highlight2', 'Access to all tools'),
t('plan.free.highlight3', 'Community support'),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: false },
{ name: t('plan.feature.api', 'API Access'), included: false },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
],
maxUsers: 5,
},
{
id: 'pro',
name: t('plan.pro.name', 'Pro'),
price: 8,
currency: '£',
period: t('plan.period.perUserPerMonth', '/user/month'),
popular: true,
highlights: [
t('plan.pro.highlight1', 'Unlimited Tool Usage per user'),
t('plan.pro.highlight2', 'Advanced PDF tools'),
t('plan.pro.highlight3', 'No watermarks'),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
],
maxUsers: 'Unlimited users',
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
price: 0,
currency: '',
period: '',
highlights: [
t('plan.enterprise.highlight1', 'Custom pricing'),
t('plan.enterprise.highlight2', 'Dedicated support'),
t('plan.enterprise.highlight3', 'Latest features'),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
],
maxUsers: 'Custom',
},
];
const getCurrentPlan = () => {
if (!currentLicenseInfo) return staticPlans[0];
if (currentLicenseInfo.planName === 'Enterprise') return staticPlans[2];
if (currentLicenseInfo.maxUsers > 5) return staticPlans[1];
return staticPlans[0];
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Current Plan Section */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<Stack gap="xs">
<Group gap="sm">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
<Badge color="green" variant="light">
{t('subscription.status.active', 'Active')}
</Badge>
</Group>
{currentLicenseInfo && (
<Text size="sm" c="dimmed">
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
{currentLicenseInfo.grandfathered &&
` (${t('workspace.people.license.grandfathered', 'Grandfathered')})`}
</Text>
)}
</Stack>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
</Text>
</div>
</Group>
</Card>
</div>
{/* Available Plans */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
}}
>
{staticPlans.map((plan) => (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{plan.popular && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
</Text>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'filled' : 'outline'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
</Stack>
</Card>
))}
</div>
</div>
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
};
export default StaticPlanSection;

View File

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import licenseService, {
PlanTier,
SubscriptionInfo,
PlansResponse,
} from '@app/services/licenseService';
export interface UsePlansReturn {
plans: PlanTier[];
currentSubscription: SubscriptionInfo | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [currentSubscription, setCurrentSubscription] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
setCurrentSubscription(data.currentSubscription);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
return {
plans,
currentSubscription,
loading,
error,
refetch: fetchPlans,
};
};

View File

@ -0,0 +1,91 @@
import apiClient from '@app/services/apiClient';
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: string[];
isContactOnly?: boolean;
}
export interface SubscriptionInfo {
plan: PlanTier;
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing' | 'none';
currentPeriodEnd?: string;
cancelAtPeriodEnd?: boolean;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
}
export interface PlansResponse {
plans: PlanTier[];
currentSubscription: SubscriptionInfo;
}
export interface CheckoutSessionRequest {
planId: string;
currency: string;
successUrl: string;
cancelUrl: string;
}
export interface CheckoutSessionResponse {
clientSecret: string;
sessionId: string;
}
export interface BillingPortalResponse {
url: string;
}
const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
const response = await apiClient.get<PlansResponse>(`/api/v1/license/plans`, {
params: { currency },
});
return response.data;
},
/**
* Get current subscription details
*/
async getCurrentSubscription(): Promise<SubscriptionInfo> {
const response = await apiClient.get<SubscriptionInfo>('/api/v1/license/subscription');
return response.data;
},
/**
* Create a Stripe checkout session for upgrading
*/
async createCheckoutSession(request: CheckoutSessionRequest): Promise<CheckoutSessionResponse> {
const response = await apiClient.post<CheckoutSessionResponse>(
'/api/v1/license/checkout',
request
);
return response.data;
},
/**
* Create a Stripe billing portal session for managing subscription
*/
async createBillingPortalSession(returnUrl: string): Promise<BillingPortalResponse> {
const response = await apiClient.post<BillingPortalResponse>('/api/v1/license/billing-portal', {
returnUrl,
});
return response.data;
},
};
export default licenseService;