merge: pull latest main into SaaS

This commit is contained in:
Anthony Stirling
2026-06-08 16:28:14 +01:00
94 changed files with 3798 additions and 430 deletions
@@ -39,13 +39,15 @@ export function OverviewHeader() {
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
{t("account.overview.signedInAs", "Signed in as: {{email}}", {
email: user.email,
})}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
{t("account.overview.logOut", "Log out")}
</Button>
)}
</div>
@@ -809,7 +809,10 @@ export default function AdminAdvancedSection() {
},
})
}
placeholder="Default: java.io.tmpdir/stirling-pdf"
placeholder={t(
"admin.settings.advanced.tempFileManagement.baseTmpDir.placeholder",
"Default: java.io.tmpdir/stirling-pdf",
)}
disabled={!loginEnabled}
/>
</div>
@@ -834,7 +837,10 @@ export default function AdminAdvancedSection() {
},
})
}
placeholder="Default: baseTmpDir/libreoffice"
placeholder={t(
"admin.settings.advanced.tempFileManagement.libreofficeDir.placeholder",
"Default: baseTmpDir/libreoffice",
)}
disabled={!loginEnabled}
/>
</div>
@@ -859,7 +865,10 @@ export default function AdminAdvancedSection() {
},
})
}
placeholder="System temp directory path"
placeholder={t(
"admin.settings.advanced.tempFileManagement.systemTempDir.placeholder",
"System temp directory path",
)}
disabled={!loginEnabled}
/>
</div>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
NumberInput,
@@ -52,6 +53,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
@@ -462,7 +464,16 @@ export default function AdminDatabaseSection() {
)}
</Text>
</div>
<Badge color="grape" size="lg">
<Badge
color="grape"
size="lg"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
ENTERPRISE
</Badge>
</Group>
@@ -698,7 +709,10 @@ export default function AdminDatabaseSection() {
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter database password"
placeholder={t(
"admin.settings.database.password.placeholder",
"Enter database password",
)}
disabled={!loginEnabled}
/>
</div>
@@ -296,7 +296,10 @@ export default function AdminEndpointsSection() {
}))}
searchable
clearable
placeholder="Select endpoints to disable"
placeholder={t(
"admin.settings.endpoints.toRemove.placeholder",
"Select endpoints to disable",
)}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
@@ -330,7 +333,10 @@ export default function AdminEndpointsSection() {
}))}
searchable
clearable
placeholder="Select groups to disable"
placeholder={t(
"admin.settings.endpoints.groupsToRemove.placeholder",
"Select groups to disable",
)}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
@@ -286,7 +286,10 @@ export default function AdminMailSection() {
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter SMTP password"
placeholder={t(
"admin.settings.mail.password.placeholder",
"Enter SMTP password",
)}
/>
</div>
@@ -183,8 +183,14 @@ const AdminPlanSection: React.FC = () => {
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
<Alert
color="yellow"
title={t("admin.settings.plan.noData.title", "No data available")}
>
{t(
"admin.settings.plan.noData.message",
"Plans data is not available at the moment.",
)}
</Alert>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import {
TextInput,
Switch,
@@ -132,17 +132,29 @@ export default function AdminPremiumSection() {
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Custom Metadata</strong> (PRO) - General
<Trans
i18nKey="admin.settings.premium.movedFeatures.customMetadata"
defaults="<0>Custom Metadata</0> (PRO) - General"
components={[<strong />]}
/>
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Audit Logging</strong> (ENTERPRISE) - Security
<Trans
i18nKey="admin.settings.premium.movedFeatures.auditLogging"
defaults="<0>Audit Logging</0> (ENTERPRISE) - Security"
components={[<strong />]}
/>
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Database Configuration</strong> (ENTERPRISE) - Database
<Trans
i18nKey="admin.settings.premium.movedFeatures.databaseConfiguration"
defaults="<0>Database Configuration</0> (ENTERPRISE) - Database"
components={[<strong />]}
/>
</Text>
</List.Item>
</List>
@@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
NumberInput,
@@ -67,6 +68,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
@@ -817,7 +819,16 @@ export default function AdminSecuritySection() {
<Text fw={600} size="sm">
{t("admin.settings.security.audit.label", "Audit Logging")}
</Text>
<Badge color="grape" size="sm">
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
ENTERPRISE
</Badge>
</Group>
@@ -11,7 +11,10 @@ import {
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
@@ -32,6 +35,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [showComparison, setShowComparison] = useState(false);
// Static checkout modal state
@@ -88,8 +93,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0,
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
highlights: planHighlights.FREE,
features: planFeatures.FREE,
maxUsers: 5,
},
{
@@ -99,8 +104,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
highlights: planHighlights.SERVER_MONTHLY,
features: planFeatures.SERVER,
maxUsers: "Unlimited users",
},
{
@@ -109,8 +114,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
price: 0,
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_MONTHLY,
features: planFeatures.ENTERPRISE,
maxUsers: "Custom",
},
];
@@ -185,14 +185,20 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
return (
<Stack align="center" justify="center" p="xl">
<Loader size="lg" />
<Text c="dimmed">Loading session...</Text>
<Text c="dimmed">
{t("workflow.participant.loadingSession", "Loading session...")}
</Text>
</Stack>
);
}
if (error) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
<Alert
icon={<InfoIcon fontSize="small" />}
color="red"
title={t("workflow.participant.errorTitle", "Error")}
>
{error}
</Alert>
);
@@ -201,7 +207,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
if (!session || !participant) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Session not found or access denied.
{t(
"workflow.participant.sessionNotFound",
"Session not found or access denied.",
)}
</Alert>
);
}
@@ -209,15 +218,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const getStatusBadge = (status: string) => {
switch (status) {
case "SIGNED":
return <Badge color="green">Signed</Badge>;
return (
<Badge color="green">
{t("workflow.participant.statusSigned", "Signed")}
</Badge>
);
case "DECLINED":
return <Badge color="red">Declined</Badge>;
return (
<Badge color="red">
{t("workflow.participant.statusDeclined", "Declined")}
</Badge>
);
case "VIEWED":
return <Badge color="blue">Viewed</Badge>;
return (
<Badge color="blue">
{t("workflow.participant.statusViewed", "Viewed")}
</Badge>
);
case "NOTIFIED":
return <Badge color="yellow">Notified</Badge>;
return (
<Badge color="yellow">
{t("workflow.participant.statusNotified", "Notified")}
</Badge>
);
case "PENDING":
return <Badge color="gray">Pending</Badge>;
return (
<Badge color="gray">
{t("workflow.participant.statusPending", "Pending")}
</Badge>
);
default:
return <Badge>{status}</Badge>;
}
@@ -254,7 +283,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.documentName}
</Text>
<Text size="sm" c="dimmed">
From: {session.ownerUsername}
{t("workflow.participant.from", "From: {{name}}", {
name: session.ownerUsername,
})}
</Text>
</div>
{getStatusBadge(participant.status)}
@@ -272,7 +303,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{session.dueDate && (
<Text size="sm" c="dimmed">
Due Date: {session.dueDate}
{t("workflow.participant.dueDate", "Due Date: {{date}}", {
date: session.dueDate,
})}
</Text>
)}
@@ -283,7 +316,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onClick={() => downloadDocument(token)}
variant="light"
>
Download Document
{t("workflow.participant.downloadDocument", "Download Document")}
</Button>
</Group>
</Stack>
@@ -293,17 +326,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="md">
<Text fw={500} size="lg">
Sign Document
{t("workflow.participant.signDocument", "Sign Document")}
</Text>
<Select
label="Certificate Type"
label={t(
"workflow.participant.certificateType",
"Certificate Type",
)}
value={certType}
onChange={(value) => setCertType(value || "P12")}
data={[
{ value: "P12", label: "P12/PKCS12 Certificate" },
{ value: "JKS", label: "JKS Keystore" },
{ value: "SERVER", label: "Server Certificate (if available)" },
{
value: "P12",
label: t(
"workflow.participant.certTypeP12",
"P12/PKCS12 Certificate",
),
},
{
value: "JKS",
label: t("workflow.participant.certTypeJks", "JKS Keystore"),
},
{
value: "SERVER",
label: t(
"workflow.participant.certTypeServer",
"Server Certificate (if available)",
),
},
]}
size="sm"
data-testid="cert-type-select"
@@ -312,8 +363,14 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{certType !== "SERVER" && (
<>
<FileInput
label="Certificate File"
placeholder="Select certificate file"
label={t(
"workflow.participant.certificateFile",
"Certificate File",
)}
placeholder={t(
"workflow.participant.certificateFilePlaceholder",
"Select certificate file",
)}
value={certFile}
onChange={setCertFile}
accept=".p12,.pfx,.jks"
@@ -322,7 +379,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
/>
<TextInput
label="Certificate Password"
label={t(
"workflow.participant.certificatePassword",
"Certificate Password",
)}
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
@@ -386,23 +446,32 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
)}
<TextInput
label="Location"
placeholder="e.g., San Francisco, CA"
label={t("workflow.participant.location", "Location")}
placeholder={t(
"workflow.participant.locationPlaceholder",
"e.g., San Francisco, CA",
)}
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Reason"
placeholder="e.g., Document approval"
label={t("workflow.participant.reason", "Reason")}
placeholder={t(
"workflow.participant.reasonPlaceholder",
"e.g., Document approval",
)}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Page Number (optional)"
label={t(
"workflow.participant.pageNumber",
"Page Number (optional)",
)}
type="number"
value={pageNumber}
onChange={(e) =>
@@ -423,7 +492,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
color="green"
data-testid="submit-signature-button"
>
Submit Signature
{t("workflow.participant.submitSignature", "Submit Signature")}
</Button>
<Button
@@ -433,7 +502,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
variant="light"
data-testid="decline-button"
>
Decline
{t("workflow.participant.decline", "Decline")}
</Button>
</Group>
</Stack>
@@ -442,14 +511,24 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
this document.
{participant.status === "SIGNED"
? t(
"workflow.participant.completedSigned",
"You have signed this document.",
)
: t(
"workflow.participant.completedDeclined",
"You have declined this document.",
)}
</Alert>
)}
{participant.isExpired && (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Your access to this document has expired.
{t(
"workflow.participant.accessExpired",
"Your access to this document has expired.",
)}
</Alert>
)}
</Stack>
@@ -1,100 +1,219 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { PlanFeature } from "@app/types/license";
/**
* Shared plan feature definitions for Stirling PDF Self-Hosted
* Used by both dynamic (Stripe) and static (fallback) plan displays
* Used by both dynamic (Stripe) and static (fallback) plan displays.
*
* These are exposed as hooks so that every feature/highlight string can be
* localized via `t()` while preserving the original shape.
*/
export const PLAN_FEATURES = {
FREE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: true },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: false },
{ name: "External Database", included: false },
{ name: "Editing text in pdfs", included: false },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: false },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
export interface PlanFeaturesMap {
FREE: PlanFeature[];
SERVER: PlanFeature[];
ENTERPRISE: PlanFeature[];
}
SERVER: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "Up to 5 users", included: false },
{ name: "Unlimited users", included: true },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: true },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
export interface PlanHighlightsMap {
FREE: string[];
SERVER_MONTHLY: string[];
SERVER_YEARLY: string[];
ENTERPRISE_MONTHLY: string[];
ENTERPRISE_YEARLY: string[];
}
ENTERPRISE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: false },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: true },
{ name: "SSO", included: true },
{ name: "SAML", included: true },
{ name: "Auditing", included: true },
{ name: "Usage tracking", included: true },
{ name: "Prometheus Support", included: true },
{ name: "Custom PDF metadata", included: true },
] as PlanFeature[],
} as const;
export const usePlanFeatures = (): PlanFeaturesMap => {
const { t } = useTranslation();
export const PLAN_HIGHLIGHTS = {
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
SERVER_MONTHLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Cancel anytime",
],
SERVER_YEARLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Save with annual billing",
],
ENTERPRISE_MONTHLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Per-seat licensing",
],
ENTERPRISE_YEARLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Save with annual billing",
],
} as const;
return useMemo(() => {
const selfHostedDeployment = t(
"plan.features.selfHostedDeployment",
"Self-hosted deployment",
);
const allPdfOperations = t(
"plan.features.allPdfOperations",
"All PDF operations",
);
const secureLoginSupport = t(
"plan.features.secureLoginSupport",
"Secure Login Support",
);
const communitySupport = t(
"plan.features.communitySupport",
"Community support",
);
const regularUpdates = t("plan.features.regularUpdates", "Regular updates");
const upToFiveUsersLowercase = t(
"plan.features.upToFiveUsersLowercase",
"up to 5 users",
);
const upToFiveUsers = t("plan.features.upToFiveUsers", "Up to 5 users");
const unlimitedUsers = t("plan.features.unlimitedUsers", "Unlimited users");
const googleDriveIntegration = t(
"plan.features.googleDriveIntegration",
"Google drive integration",
);
const externalDatabase = t(
"plan.features.externalDatabase",
"External Database",
);
const editingTextInPdfs = t(
"plan.features.editingTextInPdfs",
"Editing text in pdfs",
);
const usersLimitedToSeats = t(
"plan.features.usersLimitedToSeats",
"Users limited to seats",
);
const sso = t("plan.features.sso", "SSO");
const saml = t("plan.features.saml", "SAML");
const auditing = t("plan.features.auditing", "Auditing");
const usageTracking = t("plan.features.usageTracking", "Usage tracking");
const prometheusSupport = t(
"plan.features.prometheusSupport",
"Prometheus Support",
);
const customPdfMetadata = t(
"plan.features.customPdfMetadata",
"Custom PDF metadata",
);
return {
FREE: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: true },
{ name: unlimitedUsers, included: false },
{ name: googleDriveIntegration, included: false },
{ name: externalDatabase, included: false },
{ name: editingTextInPdfs, included: false },
{ name: usersLimitedToSeats, included: false },
{ name: sso, included: false },
{ name: saml, included: false },
{ name: auditing, included: false },
{ name: usageTracking, included: false },
{ name: prometheusSupport, included: false },
{ name: customPdfMetadata, included: false },
],
SERVER: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsers, included: false },
{ name: unlimitedUsers, included: true },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
{ name: usersLimitedToSeats, included: false },
{ name: sso, included: true },
{ name: saml, included: false },
{ name: auditing, included: false },
{ name: usageTracking, included: false },
{ name: prometheusSupport, included: false },
{ name: customPdfMetadata, included: false },
],
ENTERPRISE: [
{ name: selfHostedDeployment, included: true },
{ name: allPdfOperations, included: true },
{ name: secureLoginSupport, included: true },
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: false },
{ name: unlimitedUsers, included: false },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
{ name: usersLimitedToSeats, included: true },
{ name: sso, included: true },
{ name: saml, included: true },
{ name: auditing, included: true },
{ name: usageTracking, included: true },
{ name: prometheusSupport, included: true },
{ name: customPdfMetadata, included: true },
],
};
}, [t]);
};
export const usePlanHighlights = (): PlanHighlightsMap => {
const { t } = useTranslation();
return useMemo(() => {
const selfHostedOnInfrastructure = t(
"plan.highlights.selfHostedOnInfrastructure",
"Self-hosted on your infrastructure",
);
const unlimitedUsers = t(
"plan.highlights.unlimitedUsers",
"Unlimited users",
);
const advancedIntegrations = t(
"plan.highlights.advancedIntegrations",
"Advanced integrations",
);
const ssoOAuth = t("plan.highlights.ssoOAuth", "SSO (OAuth2/OIDC)");
const editingTextInPdfsCaps = t(
"plan.highlights.editingTextInPdfsCaps",
"Editing text in PDFs",
);
const enterpriseFeatures = t(
"plan.highlights.enterpriseFeatures",
"Enterprise features (SAML, Auditing)",
);
const usageTrackingPrometheus = t(
"plan.highlights.usageTrackingPrometheus",
"Usage tracking & Prometheus",
);
const customPdfMetadata = t(
"plan.highlights.customPdfMetadata",
"Custom PDF metadata",
);
const saveWithAnnualBilling = t(
"plan.highlights.saveWithAnnualBilling",
"Save with annual billing",
);
return {
FREE: [
t("plan.highlights.upToFiveUsers", "Up to 5 users"),
t("plan.highlights.selfHosted", "Self-hosted"),
t("plan.highlights.allBasicFeatures", "All basic features"),
],
SERVER_MONTHLY: [
selfHostedOnInfrastructure,
unlimitedUsers,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,
t("plan.highlights.cancelAnytime", "Cancel anytime"),
],
SERVER_YEARLY: [
selfHostedOnInfrastructure,
unlimitedUsers,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,
saveWithAnnualBilling,
],
ENTERPRISE_MONTHLY: [
enterpriseFeatures,
usageTrackingPrometheus,
customPdfMetadata,
t("plan.highlights.perSeatLicensing", "Per-seat licensing"),
],
ENTERPRISE_YEARLY: [
enterpriseFeatures,
usageTrackingPrometheus,
customPdfMetadata,
saveWithAnnualBilling,
],
};
}, [t]);
};
@@ -24,6 +24,10 @@ import {
import { useLicense } from "@app/contexts/LicenseContext";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
import { getPreferredCurrency } from "@app/utils/currencyDetection";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
@@ -57,6 +61,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
}) => {
const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense();
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] =
@@ -85,7 +91,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
const response = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
@@ -95,7 +105,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
setPlansLoading(false);
}
},
[plansLoading],
[plansLoading, planFeatures, planHighlights],
);
const refetchPlans = useCallback(() => {
@@ -1,8 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import licenseService, {
PlanTier,
PlansResponse,
} from "@app/services/licenseService";
import {
usePlanFeatures,
usePlanHighlights,
} from "@app/constants/planConstants";
export interface UsePlansReturn {
plans: PlanTier[];
@@ -12,16 +16,22 @@ export interface UsePlansReturn {
}
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
const planFeatures = usePlanFeatures();
const planHighlights = usePlanHighlights();
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
const fetchPlans = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
const data: PlansResponse = await licenseService.getPlans(
planFeatures,
planHighlights,
currency,
);
setPlans(data.plans);
} catch (err) {
console.error("Error fetching plans:", err);
@@ -29,11 +39,11 @@ export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
} finally {
setLoading(false);
}
};
}, [currency, planFeatures, planHighlights]);
useEffect(() => {
fetchPlans();
}, [currency]);
}, [fetchPlans]);
return {
plans,
@@ -1,7 +1,10 @@
import apiClient from "@app/services/apiClient";
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
import { getCheckoutMode } from "@app/utils/protocolDetection";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import type {
PlanFeaturesMap,
PlanHighlightsMap,
} from "@app/constants/planConstants";
import type { LicenseInfo, PlanFeature } from "@app/types/license";
export interface PlanTier {
@@ -102,9 +105,18 @@ const SELF_HOSTED_LOOKUP_KEYS = [
const licenseService = {
/**
* Get available plans with pricing for the specified currency
* Get available plans with pricing for the specified currency.
*
* Feature and highlight labels are passed in by the caller (a React hook /
* context that resolves them via `usePlanFeatures` / `usePlanHighlights`),
* which keeps this service free of React/i18n dependencies while still
* returning fully localized plan content.
*/
async getPlans(currency: string = "usd"): Promise<PlansResponse> {
async getPlans(
planFeatures: PlanFeaturesMap,
planHighlights: PlanHighlightsMap,
currency: string = "usd",
): Promise<PlansResponse> {
try {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
@@ -179,8 +191,8 @@ const licenseService = {
currency: currencySymbol,
period: "/month",
popular: false,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: planFeatures.SERVER,
highlights: planHighlights.SERVER_MONTHLY,
},
{
id: "selfhosted:server:yearly",
@@ -190,8 +202,8 @@ const licenseService = {
currency: currencySymbol,
period: "/year",
popular: true,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY,
features: planFeatures.SERVER,
highlights: planHighlights.SERVER_YEARLY,
},
{
id: "selfhosted:enterprise:monthly",
@@ -203,8 +215,8 @@ const licenseService = {
period: "/month",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: planFeatures.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_MONTHLY,
},
{
id: "selfhosted:enterprise:yearly",
@@ -216,8 +228,8 @@ const licenseService = {
period: "/year",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY,
features: planFeatures.ENTERPRISE,
highlights: planHighlights.ENTERPRISE_YEARLY,
},
];
@@ -240,8 +252,8 @@ const licenseService = {
currency: currencySymbol,
period: "",
popular: false,
features: PLAN_FEATURES.FREE,
highlights: PLAN_HIGHLIGHTS.FREE,
features: planFeatures.FREE,
highlights: planHighlights.FREE,
};
const allPlans = [freePlan, ...validPlans];