mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fix(i18n): wrap hard-coded English strings in t() across UI (#6566)
## Summary
Audit + bulk fix of hard-coded English UI strings - `aria-label`,
`title`, `placeholder`, `label`, and raw JSX literals that bypassed i18n
entirely. Each literal now goes through `t("key", "English Default")`
from `react-i18next`, and every new key has a corresponding entry in
`en-GB/translation.toml` so translators can pick it up.
## What this fixes
Strings were rendered untranslated in every non-EN locale because they
never went through `t()` at all (not just "value not translated yet").
Affects screen-reader labels, tooltips, form placeholders, empty/loading
states, plan card content, and the entire workflow ParticipantView.
## Coverage (~143 keys / 50 files)
- **Viewer chrome** - search bar (close, clear, prev/next, "of N"
results), link/signature/redaction actions, viewer error state, zoom
labels
- **Page editor** - undo/redo/rotate/delete toolbar tooltips, empty
state, bulk selection operator chip tooltips
- **Shared primitives** - Tooltip close, InfoBanner dismiss, TextInput
clear, Toast dismiss/toggle, UpdateModal close, EditableSecretField
edit, DropdownListWithFooter search, FileCard/FileDropdownMenu actions,
EmptyFilesState + AddFileCard upload
- **Tools** - Image upload + hint, ColorControl eyedropper, sign Use
Signature, CompressSettings, OCR loading, PageLayout
margin/border/row/col placeholders, FormFill switch + save + re-scan
- **Proprietary admin** - OverviewHeader signed-in line + logout,
AdminPremiumSection moved-features list (via `<Trans>`),
AdminPlanSection no-data alert, AdminAdvancedSection temp-dir
placeholders, AdminEndpointsSection multiselect placeholders,
AdminMailSection + AdminDatabaseSection password placeholders
- **Onboarding** - MFASetupSlide QR loading + auth code label,
SecurityCheckSlide role select + options
- **ParticipantView** - entire sign-document UI (~30 strings: loading,
error, badges, headings, cert-type Select, all input labels and
placeholders, action buttons, completion + expired alerts) - file
previously imported `useTranslation` but only used `t()` for cert
validation
- **planConstants.ts refactor** - replaced `PLAN_FEATURES` /
`PLAN_HIGHLIGHTS` const exports with `usePlanFeatures()` /
`usePlanHighlights()` hooks. Service layer (`licenseService.getPlans`)
updated to accept feature/highlight maps so it stays hook-free. Callers
(`usePlans`, `CheckoutContext`) resolve the hooks at the React boundary
- **Previously catalogued offenders** - `FileSidebarFileItem`
open/close-viewer aria-labels, `quickAccessBar/ActiveToolButton` "Back
to all tools" tooltip + aria, `AppConfigModal` close button
## Notes
- One small refactor in `usePageSelectionTips.ts` was needed to resolve
a TOML key-shape conflict: the existing scalar keys
`bulkSelection.operators.{and,not,comma}` needed to become tables to
hold the new `.title` subkeys for OperatorsSection's chip tooltips. The
existing descriptions moved to `[bulkSelection.operators.descriptions]`
and the three i18n key paths in usePageSelectionTips were updated to
match.
- Viewer sidebar close buttons
(Bookmark/Layer/Thumbnail/Attachment/Comments) were on the audit list
but are NOT on main - they're added by the unmerged PR #6552
(feat/viewer-sidebar-ux). Those particular strings will need wrapping
when that PR lands.
- TOML hook (`toml-sort-fix`) ran and re-sorted the translation file.
## Test plan
- [ ] `task frontend:typecheck` passes (core + proprietary + desktop
variants)
- [ ] `task frontend:lint` passes
- [ ] Switching language to Deutsch / Русский: previously-English
`aria-label`s + tooltips + placeholders + plan card bullets now render
translated (when the locale has values) or fall back to the English
default (when it doesn't)
- [ ] Plan page bullet points in EN render unchanged
- [ ] Sign-document flow (ParticipantView) renders unchanged in EN
This commit is contained in:
@@ -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>
|
||||
|
||||
+12
-3
@@ -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>
|
||||
|
||||
+4
-1
@@ -698,7 +698,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>
|
||||
|
||||
+8
-2
@@ -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}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
|
||||
+8
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-4
@@ -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>
|
||||
|
||||
+12
-7
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user