From b47a31c34f68b5b73ee32852fa22f6b945123cd2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:28:47 +0000 Subject: [PATCH 01/19] init stripe --- .../api/AdminSettingsController.java | 10 + frontend/package-lock.json | 67 ++-- frontend/package.json | 2 + .../components/shared/ManageBillingButton.tsx | 37 ++ .../core/components/shared/StripeCheckout.tsx | 178 +++++++++ .../shared/config/configNavSections.tsx | 10 +- .../configSections/AdminPlanSection.tsx | 287 +++++++++++++++ .../configSections/plan/ActivePlanSection.tsx | 89 +++++ .../plan/AvailablePlansSection.tsx | 131 +++++++ .../config/configSections/plan/PlanCard.tsx | 83 +++++ .../configSections/plan/StaticPlanSection.tsx | 344 ++++++++++++++++++ frontend/src/core/hooks/usePlans.ts | 49 +++ frontend/src/core/services/licenseService.ts | 91 +++++ 13 files changed, 1336 insertions(+), 42 deletions(-) create mode 100644 frontend/src/core/components/shared/ManageBillingButton.tsx create mode 100644 frontend/src/core/components/shared/StripeCheckout.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/ActivePlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/StaticPlanSection.tsx create mode 100644 frontend/src/core/hooks/usePlans.ts create mode 100644 frontend/src/core/services/licenseService.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 27c924ae4..a4bdd9da5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 entry : sectionData.entrySet()) { String propertyKey = entry.getKey(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43ee35e16..365a6ab15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 825749f3e..32238cfd1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/core/components/shared/ManageBillingButton.tsx b/frontend/src/core/components/shared/ManageBillingButton.tsx new file mode 100644 index 000000000..3cbc07f4c --- /dev/null +++ b/frontend/src/core/components/shared/ManageBillingButton.tsx @@ -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 = ({ + 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 ( + + ); +}; diff --git a/frontend/src/core/components/shared/StripeCheckout.tsx b/frontend/src/core/components/shared/StripeCheckout.tsx new file mode 100644 index 000000000..ca1870b79 --- /dev/null +++ b/frontend/src/core/components/shared/StripeCheckout.tsx @@ -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 = ({ + opened, + onClose, + planId, + planName, + planPrice, + currency, + onSuccess, + onError, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ 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 ( +
+ + + {t('payment.preparing', 'Preparing your checkout...')} + +
+ ); + + case 'ready': + if (!state.clientSecret) return null; + + return ( + + + + ); + + case 'success': + return ( + + + + {t( + 'payment.successMessage', + 'Your subscription has been activated successfully. You will receive a confirmation email shortly.' + )} + + + {t('payment.autoClose', 'This window will close automatically...')} + + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + default: + return null; + } + }; + + return ( + + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })} + + + {currency} + {planPrice}/{t('plan.period.month', 'month')} + + + } + size="xl" + centered + withCloseButton={state.status !== 'ready'} + closeOnEscape={state.status !== 'ready'} + closeOnClickOutside={state.status !== 'ready'} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default StripeCheckout; diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index cbef8d27e..221817b04 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -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: + key: 'adminPlan', + label: 'Plan', + icon: 'receipt-long-rounded', + component: }, { key: 'adminAudit', diff --git a/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx new file mode 100644 index 000000000..6f44fac6d --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx @@ -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(null); + const [currency, setCurrency] = useState('gbp'); + const [useStaticVersion, setUseStaticVersion] = useState(false); + const [currentLicenseInfo, setCurrentLicenseInfo] = useState(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({ + 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 ; + } + + // Early returns after all hooks are called + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + // Fallback to static version on error + return ; + } + + if (!plans || !currentSubscription) { + return ( + + Plans data is not available at the moment. + + ); + } + + return ( +
+ {/* Currency Selector */} +
+ + + {t('plan.currency', 'Currency')} + + setCurrency(value || 'gbp')} - data={currencyOptions} - searchable - clearable={false} - w={300} + setEmail(e.target.value)} + required + type="email" /> - -
+ + + {t('plan.currency', 'Currency')} + + setCurrency(value || 'gbp')} + data={currencyOptions} + searchable + clearable={false} + w={300} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} /> - - - {t('plan.currency', 'Currency')} - - setCurrency(value || 'gbp')} - data={currencyOptions} - searchable - clearable={false} - w={300} - comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} - /> - - + + + + {t('plan.currency', 'Currency')} + + onFontFamilyChange(value || 'Helvetica')} data={fontOptions} @@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC = ({ > { const value = event.currentTarget.value; @@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC = ({ {onTextColorChange && ( !disabled && setIsColorPickerOpen(true)} - style={{ cursor: disabled ? 'default' : 'pointer' }} + onChange={(e) => { + const value = e.currentTarget.value; + setColorInput(value); + + // Update color if valid hex + if (isValidHexColor(value)) { + onTextColorChange(value); + } + }} + onBlur={() => { + // Revert to valid color on blur if invalid + if (!isValidHexColor(colorInput)) { + setColorInput(textColor); + } + }} + style={{ width: '100%' }} rightSection={ !disabled && setIsColorPickerOpen(true)} style={{ width: 24, height: 24, @@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC = ({ )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 09200727d..6c2599d0c 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from "react-i18next"; -import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core'; +import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core'; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; import { useSignature } from "@app/contexts/SignatureContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from '@app/constants/signConstants'; // Import the new reusable components import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; @@ -11,6 +13,18 @@ import { DrawingControls } from "@app/components/annotation/shared/DrawingContro import { ImageUploader } from "@app/components/annotation/shared/ImageUploader"; import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont"; import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; + +type SignatureDrafts = { + canvas?: string; + image?: string; + text?: { + signerName: string; + fontSize: number; + fontFamily: string; + textColor: string; + }; +}; interface SignSettingsProps { parameters: SignParameters; @@ -31,23 +45,64 @@ const SignSettings = ({ disabled = false, onActivateSignaturePlacement, onDeactivateSignature, + onUpdateDrawSettings, onUndo, onRedo, onSave }: SignSettingsProps) => { const { t } = useTranslation(); - const { isPlacementMode } = useSignature(); + const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature(); + const { activeFileIndex } = useViewer(); + const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); + const historyApiInstance = historyApiRef.current; // State for drawing const [selectedColor, setSelectedColor] = useState('#000000'); const [penSize, setPenSize] = useState(2); const [penSizeInput, setPenSizeInput] = useState('2'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move'); + const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); // State for different signature types - const [canvasSignatureData, setCanvasSignatureData] = useState(null); - const [imageSignatureData, setImageSignatureData] = useState(null); + const [canvasSignatureData, setCanvasSignatureData] = useState(); + const [imageSignatureData, setImageSignatureData] = useState(); + const [signatureDrafts, setSignatureDrafts] = useState({}); + const lastSyncedTextDraft = useRef(null); + const lastAppliedPlacementKey = useRef(null); + const previousFileIndexRef = useRef(activeFileIndex); + + useEffect(() => { + if (!disabled) { + onUpdateDrawSettings?.(selectedColor, penSize); + } + }, [selectedColor, penSize, disabled, onUpdateDrawSettings]); + + useEffect(() => { + if (signaturesApplied) { + setPlacementManuallyPaused(false); + } + }, [signaturesApplied]); + + useEffect(() => { + if (!historyApiInstance) { + setHistoryAvailability({ canUndo: false, canRedo: false }); + return; + } + + const updateAvailability = () => { + setHistoryAvailability({ + canUndo: historyApiInstance.canUndo?.() ?? false, + canRedo: historyApiInstance.canRedo?.() ?? false, + }); + }; + + const unsubscribe = historyApiInstance.subscribe?.(updateAvailability); + updateAvailability(); + + return () => { + unsubscribe?.(); + }; + }, [historyApiInstance]); // Handle image upload const handleImageChange = async (file: File | null) => { @@ -66,64 +121,209 @@ const SignSettings = ({ reader.readAsDataURL(file); }); - // Clear any existing canvas signatures when uploading image - setCanvasSignatureData(null); setImageSignatureData(result); } catch (error) { console.error('Error reading file:', error); } } else if (!file) { - setImageSignatureData(null); - if (onDeactivateSignature) { - onDeactivateSignature(); - } + setImageSignatureData(undefined); + onDeactivateSignature?.(); } }; // Handle signature data changes const handleCanvasSignatureChange = (data: string | null) => { + const nextValue = data ?? undefined; setCanvasSignatureData(prev => { - if (prev === data) return prev; // Prevent unnecessary updates - return data; + if (prev === nextValue) { + return prev; + } + return nextValue; }); - if (data) { - // Clear image data when canvas is used - setImageSignatureData(null); - } }; - // Handle signature mode deactivation when switching types - useEffect(() => { - if (parameters.signatureType !== 'text' && onDeactivateSignature) { - onDeactivateSignature(); - } - }, [parameters.signatureType]); + const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]); + const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]); + const hasTextSignature = useMemo( + () => Boolean(parameters.signerName && parameters.signerName.trim() !== ''), + [parameters.signerName] + ); + + const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature; + + const isCurrentTypeReady = useMemo(() => { + switch (parameters.signatureType) { + case 'canvas': + return hasCanvasSignature; + case 'image': + return hasImageSignature; + case 'text': + return hasTextSignature; + default: + return false; + } + }, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]); + + const placementSignatureKey = useMemo(() => { + if (!isCurrentTypeReady) { + return null; + } + + switch (parameters.signatureType) { + case 'canvas': + return canvasSignatureData ?? null; + case 'image': + return imageSignatureData ?? null; + case 'text': + return JSON.stringify({ + signerName: (parameters.signerName ?? '').trim(), + fontSize: parameters.fontSize ?? 16, + fontFamily: parameters.fontFamily ?? 'Helvetica', + textColor: parameters.textColor ?? '#000000', + }); + default: + return null; + } + }, [ + isCurrentTypeReady, + parameters.signatureType, + canvasSignatureData, + imageSignatureData, + parameters.signerName, + parameters.fontSize, + parameters.fontFamily, + parameters.textColor, + ]); + + const shouldEnablePlacement = useMemo(() => { + if (disabled) return false; + return isCurrentTypeReady; + }, [disabled, isCurrentTypeReady]); + + const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied; - // Handle text signature activation (including fontSize and fontFamily changes) useEffect(() => { - if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') { - if (onActivateSignaturePlacement) { - setInteractionMode('place'); - setTimeout(() => { - onActivateSignaturePlacement(); - }, 100); + setSignatureDrafts(prev => { + if (canvasSignatureData) { + if (prev.canvas === canvasSignatureData) { + return prev; + } + return { ...prev, canvas: canvasSignatureData }; } - } else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) { - if (onDeactivateSignature) { - setInteractionMode('move'); - onDeactivateSignature(); + + if (prev.canvas !== undefined) { + const next = { ...prev }; + delete next.canvas; + return next; } - } - }, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]); - // Reset to move mode when placement mode is deactivated + return prev; + }); + }, [canvasSignatureData]); + useEffect(() => { - if (!isPlacementMode && interactionMode === 'place') { - setInteractionMode('move'); - } - }, [isPlacementMode, interactionMode]); + setSignatureDrafts(prev => { + if (imageSignatureData) { + if (prev.image === imageSignatureData) { + return prev; + } + return { ...prev, image: imageSignatureData }; + } + + if (prev.image !== undefined) { + const next = { ...prev }; + delete next.image; + return next; + } + + return prev; + }); + }, [imageSignatureData]); + + useEffect(() => { + const nextDraft = { + signerName: parameters.signerName || '', + fontSize: parameters.fontSize || 16, + fontFamily: parameters.fontFamily || 'Helvetica', + textColor: parameters.textColor || '#000000', + }; + + setSignatureDrafts(prev => { + const prevDraft = prev.text; + if ( + prevDraft && + prevDraft.signerName === nextDraft.signerName && + prevDraft.fontSize === nextDraft.fontSize && + prevDraft.fontFamily === nextDraft.fontFamily && + prevDraft.textColor === nextDraft.textColor + ) { + return prev; + } + + return { ...prev, text: nextDraft }; + }); + }, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]); + + useEffect(() => { + if (parameters.signatureType === 'text') { + const draft = signatureDrafts.text; + if (!draft) { + lastSyncedTextDraft.current = null; + return; + } + + const currentSignerName = parameters.signerName ?? ''; + const currentFontSize = parameters.fontSize ?? 16; + const currentFontFamily = parameters.fontFamily ?? 'Helvetica'; + const currentTextColor = parameters.textColor ?? '#000000'; + + const isSynced = + draft.signerName === currentSignerName && + draft.fontSize === currentFontSize && + draft.fontFamily === currentFontFamily && + draft.textColor === currentTextColor; + + if (isSynced) { + lastSyncedTextDraft.current = draft; + return; + } + + const lastSynced = lastSyncedTextDraft.current; + const alreadyAttempted = + lastSynced && + lastSynced.signerName === draft.signerName && + lastSynced.fontSize === draft.fontSize && + lastSynced.fontFamily === draft.fontFamily && + lastSynced.textColor === draft.textColor; + + if (!alreadyAttempted) { + lastSyncedTextDraft.current = draft; + if (draft.signerName !== currentSignerName) { + onParameterChange('signerName', draft.signerName); + } + if (draft.fontSize !== currentFontSize) { + onParameterChange('fontSize', draft.fontSize); + } + if (draft.fontFamily !== currentFontFamily) { + onParameterChange('fontFamily', draft.fontFamily); + } + if (draft.textColor !== currentTextColor) { + onParameterChange('textColor', draft.textColor); + } + } + } else { + lastSyncedTextDraft.current = null; + } + }, [ + parameters.signatureType, + parameters.signerName, + parameters.fontSize, + parameters.fontFamily, + parameters.textColor, + signatureDrafts.text, + onParameterChange, + ]); - // Handle signature data updates useEffect(() => { let newSignatureData: string | undefined = undefined; @@ -133,71 +333,99 @@ const SignSettings = ({ newSignatureData = canvasSignatureData; } - // Only update if the signature data has actually changed if (parameters.signatureData !== newSignatureData) { onParameterChange('signatureData', newSignatureData); } - }, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]); + }, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]); - // Handle image signature activation - activate when image data syncs with parameters useEffect(() => { - if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) { - setInteractionMode('place'); - setTimeout(() => { - onActivateSignaturePlacement(); - }, 100); + if (!shouldEnablePlacement) { + if (isPlacementMode) { + onDeactivateSignature?.(); + } + if (isPlacementManuallyPaused) { + setPlacementManuallyPaused(false); + } + return; } - }, [parameters.signatureType, parameters.signatureData, imageSignatureData]); - // Handle canvas signature activation - activate when canvas data syncs with parameters + if (!shouldAutoActivate || isPlacementMode) { + return; + } + + if (typeof window !== 'undefined') { + const timer = window.setTimeout(() => { + onActivateSignaturePlacement?.(); + }, PLACEMENT_ACTIVATION_DELAY); + return () => window.clearTimeout(timer); + } + + onActivateSignaturePlacement?.(); + }, [ + shouldEnablePlacement, + shouldAutoActivate, + isPlacementMode, + isPlacementManuallyPaused, + onActivateSignaturePlacement, + onDeactivateSignature, + ]); + useEffect(() => { - if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) { - setInteractionMode('place'); - setTimeout(() => { - onActivateSignaturePlacement(); - }, 100); + if (!shouldAutoActivate || !placementSignatureKey) { + if (!shouldEnablePlacement || !shouldAutoActivate) { + lastAppliedPlacementKey.current = null; + } + return; } - }, [parameters.signatureType, parameters.signatureData, canvasSignatureData]); - // Draw settings are no longer needed since draw mode is removed + if (!isPlacementMode) { + lastAppliedPlacementKey.current = null; + return; + } - return ( - - {/* Signature Type Selection */} - onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')} - > - - - {t('sign.type.canvas', 'Canvas')} - - - {t('sign.type.image', 'Image')} - - - {t('sign.type.text', 'Text')} - - - + if (lastAppliedPlacementKey.current === placementSignatureKey) { + return; + } - {/* Drawing Controls */} - { - if (onActivateSignaturePlacement) { - onActivateSignaturePlacement(); - } - }} - hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))} - disabled={disabled} - showPlaceButton={false} - placeButtonText={t('sign.updateAndPlace', 'Update and Place')} - /> + const trigger = () => { + onActivateSignaturePlacement?.(); + lastAppliedPlacementKey.current = placementSignatureKey; + }; - {/* Signature Creation based on type */} - {parameters.signatureType === 'canvas' && ( + if (typeof window !== 'undefined') { + const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY); + return () => window.clearTimeout(timer); + } + + trigger(); + }, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]); + useEffect(() => { + if (activeFileIndex === previousFileIndexRef.current) { + return; + } + + previousFileIndexRef.current = activeFileIndex; + + if (!shouldEnablePlacement || signaturesApplied) { + return; + } + + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + + if (typeof window !== 'undefined') { + const timer = window.setTimeout(() => { + onActivateSignaturePlacement?.(); + }, FILE_SWITCH_ACTIVATION_DELAY); + return () => window.clearTimeout(timer); + } + + onActivateSignaturePlacement?.(); + }, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]); + + const renderSignatureBuilder = () => { + if (parameters.signatureType === 'canvas') { + return ( { - if (onActivateSignaturePlacement) { - onActivateSignaturePlacement(); - } + onActivateSignaturePlacement?.(); }} disabled={disabled} - additionalButtons={ - - } + initialSignatureData={canvasSignatureData} /> - )} + ); + } - {parameters.signatureType === 'image' && ( + if (parameters.signatureType === 'image') { + return ( - )} + ); + } - {parameters.signatureType === 'text' && ( - onParameterChange('signerName', text)} - fontSize={parameters.fontSize || 16} - onFontSizeChange={(size) => onParameterChange('fontSize', size)} - fontFamily={parameters.fontFamily || 'Helvetica'} - onFontFamilyChange={(family) => onParameterChange('fontFamily', family)} - textColor={parameters.textColor || '#000000'} - onTextColorChange={(color) => onParameterChange('textColor', color)} - disabled={disabled} - /> - )} + return ( + onParameterChange('signerName', text)} + fontSize={parameters.fontSize || 16} + onFontSizeChange={(size) => onParameterChange('fontSize', size)} + fontFamily={parameters.fontFamily || 'Helvetica'} + onFontFamilyChange={(family) => onParameterChange('fontFamily', family)} + textColor={parameters.textColor || '#000000'} + onTextColorChange={(color) => onParameterChange('textColor', color)} + disabled={disabled} + /> + ); + }; + const placementInstructions = () => { + if (parameters.signatureType === 'canvas') { + return t('sign.instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.'); + } + if (parameters.signatureType === 'image') { + return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.'); + } + return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.'); + }; - {/* Interaction Mode Toggle */} - {(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && ( - { - setInteractionMode(value as 'move' | 'place'); - if (value === 'place') { - if (onActivateSignaturePlacement) { - onActivateSignaturePlacement(); - } - } else { - if (onDeactivateSignature) { - onDeactivateSignature(); - } - } - }} - data={[ - { label: t('sign.mode.move', 'Move Signature'), value: 'move' }, - { label: t('sign.mode.place', 'Place Signature'), value: 'place' } - ]} - fullWidth - /> - )} + const placementAlert = isCurrentTypeReady + ? { + color: isPlacementMode ? 'blue' : 'teal', + title: isPlacementMode + ? t('sign.instructions.title', 'How to add your signature') + : t('sign.instructions.paused', 'Placement paused'), + message: isPlacementMode + ? placementInstructions() + : t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'), + } + : { + color: 'yellow', + title: t('sign.instructions.title', 'How to add your signature'), + message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'), + }; - {/* Instructions for placing signature */} - - - {parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')} - {parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')} - {parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')} + const handlePausePlacement = () => { + setPlacementManuallyPaused(true); + onDeactivateSignature?.(); + }; + + const handleResumePlacement = () => { + setPlacementManuallyPaused(false); + onActivateSignaturePlacement?.(); + }; + + // Handle Escape key to toggle pause/resume + useEffect(() => { + if (!isCurrentTypeReady) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (isPlacementMode) { + handlePausePlacement(); + } else if (isPlacementManuallyPaused) { + handleResumePlacement(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isCurrentTypeReady, isPlacementMode, isPlacementManuallyPaused]); + + const placementToggleControl = + onActivateSignaturePlacement || onDeactivateSignature + ? isPlacementMode + ? ( + + + + + {t('sign.mode.pause', 'Pause placement')} + + + + ) + : ( + + + + + {t('sign.mode.resume', 'Resume placement')} + + + + ) + : null; + + return ( + + + + {t('sign.step.createDesc', 'Choose how you want to create the signature')} - + onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')} + data={[ + { label: t('sign.type.canvas', 'Draw'), value: 'canvas' }, + { label: t('sign.type.image', 'Upload'), value: 'image' }, + { label: t('sign.type.text', 'Type'), value: 'text' }, + ]} + /> + {renderSignatureBuilder()} + + + + + + + {t('sign.step.place', 'Place & save')} + + + {t('sign.step.placeDesc', 'Position the signature on your PDF')} + + + + + + {placementToggleControl} + + + + + + {placementAlert.message} + + + + - {/* Color Picker Modal */} setIsColorPickerOpen(false)} selectedColor={selectedColor} onColorChange={setSelectedColor} + title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')} /> - {/* Apply Signatures Button */} {onSave && ( - + {/* Severe warning if license already exists */} + {licenseInfo?.licenseKey && ( + } + title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')} + > + + + {t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')} + + + {t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')} + + + {t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')} + - + )} + + + + setLicenseKeyInput(e.target.value)} + placeholder="00000000-0000-0000-0000-000000000000" + type="password" + disabled={savingLicense} + /> + + + + + +
- - {/* Restart Confirmation Modal */} - ); }; From 08ae2c62ec8da6f377482ac90dcf1da920b373d2 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 19 Nov 2025 14:06:02 +0000 Subject: [PATCH 17/19] lint error --- .../components/shared/config/configSections/plan/PlanCard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index 20abc70a5..fdf11033e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -81,13 +81,11 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra let displayPrice = monthly?.price || 0; let displaySeatPrice = monthly?.seatPrice; let displayCurrency = monthly?.currency || '£'; - let isYearlyPrice = false; if (yearly) { displayPrice = Math.round(yearly.price / 12); displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined; displayCurrency = yearly.currency; - isYearlyPrice = true; } return ( From 5118b3e5f2fea88b2931ac2317c3df1009365d29 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 19 Nov 2025 14:30:27 +0000 Subject: [PATCH 18/19] translations --- .../public/locales/en-GB/translation.json | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index f48110dcc..07f2f47a9 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4240,9 +4240,21 @@ "title": "Premium & Enterprise", "description": "Configure your premium or enterprise license key.", "license": "License Configuration", + "licenseKey": { + "toggle": "Got a license key or certificate file?", + "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." + }, "key": { "label": "License Key", - "description": "Enter your premium or enterprise license key" + "description": "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.", + "success": "License Key Saved", + "successMessage": "Your license key has been activated successfully. No restart required.", + "overwriteWarning": { + "title": "⚠️ Warning: Existing License Detected", + "line1": "Overwriting your current license key cannot be undone.", + "line2": "Your previous license will be permanently lost unless you have backed it up elsewhere.", + "line3": "Important: Keep license keys private and secure. Never share them publicly." + } }, "enabled": { "label": "Enable Premium Features", @@ -4667,7 +4679,8 @@ "used": "used", "available": "available", "cancel": "Cancel", - "preview": "Preview" + "preview": "Preview", + "close": "Close" }, "config": { "overview": { @@ -5085,6 +5098,14 @@ "showComparison": "Compare All Features", "hideComparison": "Hide Feature Comparison", "featureComparison": "Feature Comparison", + "from": "From", + "perMonth": "/month", + "licensedSeats": "Licensed: {{count}} seats", + "includedInCurrent": "Included in Your Plan", + "selectPlan": "Select Plan", + "manageSubscription": { + "description": "Manage your subscription, billing, and payment methods" + }, "activePlan": { "title": "Active Plan", "subtitle": "Your current subscription details" @@ -5102,13 +5123,16 @@ "upTo": "Up to" }, "period": { - "month": "month" + "month": "month", + "perUserPerMonth": "/user/month" }, "free": { "name": "Free", "highlight1": "Limited Tool Usage Per week", "highlight2": "Access to all tools", - "highlight3": "Community support" + "highlight3": "Community support", + "forever": "Forever free", + "included": "Included" }, "pro": { "name": "Pro", @@ -5150,6 +5174,12 @@ "error": "Failed to open billing portal" } }, + "upgradeBanner": { + "title": "Upgrade to Server Plan", + "message": "Get the most out of Stirling PDF with unlimited users and advanced features", + "upgradeButton": "Upgrade Now", + "dismiss": "Dismiss banner" + }, "payment": { "preparing": "Preparing your checkout...", "upgradeTitle": "Upgrade to {{planName}}", From fb0c3d5d42a78d2923db7af33026e84df2fcf339 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 19 Nov 2025 14:32:00 +0000 Subject: [PATCH 19/19] show key if exists --- .../shared/config/configSections/AdminPlanSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index fdcd492d8..f5efa82e1 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -217,7 +217,7 @@ const AdminPlanSection: React.FC = () => { 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={licenseKeyInput} onChange={(e) => setLicenseKeyInput(e.target.value)} - placeholder="00000000-0000-0000-0000-000000000000" + placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} type="password" disabled={savingLicense} />