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
@@ -8,6 +8,7 @@ import {
Group,
} from "@mantine/core";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
@@ -33,6 +34,7 @@ export function ColorControl({
label,
disabled = false,
}: ColorControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates)
@@ -111,7 +113,9 @@ export function ColorControl({
/>
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<Tooltip
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
>
<ActionIcon
variant="subtle"
color="gray"
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
@@ -12,6 +13,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false,
}) => {
const { t } = useTranslation();
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
@@ -57,9 +59,12 @@ export const ImageTool: React.FC<ImageToolProps> = ({
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
label={t("image.upload.label", "Upload Image")}
placeholder={t("image.upload.placeholder", "Select image file")}
hint={t(
"image.upload.hint",
"Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility.",
)}
/>
</Stack>
</BaseAnnotationTool>
@@ -134,7 +134,7 @@ const AddFileCard = ({
)}
</Button>
<Button
aria-label="Upload"
aria-label={t("addFileCard.upload", "Upload")}
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
@@ -77,7 +77,7 @@ const EmptyFilesState: React.FC = () => {
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
aria-label={t("emptyFilesState.upload", "Upload")}
style={{
backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)",
@@ -16,6 +16,7 @@ import {
TextInput,
} from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
@@ -31,6 +32,7 @@ interface MFASetupSlideProps {
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const { t } = useTranslation();
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
@@ -179,7 +181,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
{mfaLoading && !isReady && (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Generating your QR code</Text>
<Text size="sm">
{t("onboarding.mfa.qrCodeLoading", "Generating your QR code…")}
</Text>
</Group>
)}
@@ -189,7 +193,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<Stack gap="sm">
<TextInput
id="mfa-setup-code"
label="Authentication code"
label={t(
"onboarding.mfa.authenticationCode",
"Authentication code",
)}
placeholder="123456"
value={mfaSetupCode}
onChange={(event) =>
@@ -37,11 +37,20 @@ export default function SecurityCheckSlide({
</div>
<Select
placeholder="Confirm your role"
placeholder={i18n.t(
"onboarding.securityCheck.rolePlaceholder",
"Confirm your role",
)}
value={selectedRole}
data={[
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
{
value: "admin",
label: i18n.t("onboarding.securityCheck.roleAdmin", "Admin"),
},
{
value: "user",
label: i18n.t("onboarding.securityCheck.roleUser", "User"),
},
]}
onChange={(value) =>
onRoleSelect((value as "admin" | "user") ?? null)
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import {
useNavigationGuard,
@@ -33,6 +34,7 @@ export interface PageEditorProps {
}
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
@@ -688,9 +690,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
<Text size="lg" c="dimmed">
📄
</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text c="dimmed">
{t("pageEditor.emptyState.title", "No PDF files loaded")}
</Text>
<Text size="sm" c="dimmed">
Add files to start editing pages
{t(
"pageEditor.emptyState.body",
"Add files to start editing pages",
)}
</Text>
</Stack>
</Center>
@@ -6,6 +6,7 @@ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import { useTranslation } from "react-i18next";
interface PageEditorControlsProps {
// Close/Reset functions
@@ -51,6 +52,7 @@ const PageEditorControls = ({
displayDocument,
splitPositions,
}: PageEditorControlsProps) => {
const { t } = useTranslation();
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
@@ -131,7 +133,7 @@ const PageEditorControls = ({
}}
>
{/* Undo/Redo */}
<Tooltip label="Undo">
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
<ActionIcon
onClick={onUndo}
disabled={!canUndo}
@@ -145,7 +147,7 @@ const PageEditorControls = ({
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
<ActionIcon
onClick={onRedo}
disabled={!canRedo}
@@ -170,7 +172,9 @@ const PageEditorControls = ({
/>
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
<Tooltip
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
>
<ActionIcon
onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0}
@@ -187,7 +191,9 @@ const PageEditorControls = ({
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Selected Right">
<Tooltip
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
>
<ActionIcon
onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0}
@@ -204,7 +210,7 @@ const PageEditorControls = ({
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Selected">
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
<ActionIcon
onClick={onDelete}
disabled={selectedPageIds.length === 0}
@@ -26,7 +26,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("and")}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
title={t(
"bulkSelection.operators.and.title",
"Combine selections (both conditions must be true)",
)}
>
<Text size="xs" fw={500}>
and
@@ -38,7 +41,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("or")}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
title={t(
"bulkSelection.operators.or.title",
"Add to selection (either condition can be true)",
)}
>
<Text size="xs" fw={500}>
or
@@ -50,7 +56,10 @@ const OperatorsSection = ({
className={classes.operatorChip}
onClick={() => onInsertOperator("not")}
disabled={!csvInput.trim()}
title="Exclude from selection"
title={t(
"bulkSelection.operators.not.title",
"Exclude from selection",
)}
>
<Text size="xs" fw={500}>
not
@@ -64,7 +73,10 @@ const OperatorsSection = ({
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("even")}
title="Select all even-numbered pages (2, 4, 6, 8...)"
title={t(
"bulkSelection.operators.even.title",
"Select all even-numbered pages (2, 4, 6, 8...)",
)}
>
<Text size="xs" fw={500}>
even
@@ -75,7 +87,10 @@ const OperatorsSection = ({
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("odd")}
title="Select all odd-numbered pages (1, 3, 5, 7...)"
title={t(
"bulkSelection.operators.odd.title",
"Select all odd-numbered pages (1, 3, 5, 7...)",
)}
>
<Text size="xs" fw={500}>
odd
@@ -45,7 +45,9 @@ const PageSelectionInput = ({
height="1rem"
style={{ color: "var(--text-instruction)" }}
/>
<Text>Page Selection</Text>
<Text>
{t("bulkSelection.pageSelection.title", "Page Selection")}
</Text>
</Flex>
</Tooltip>
{typeof advancedOpened === "boolean" && (
@@ -7,6 +7,7 @@ import React, {
} from "react";
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
@@ -34,6 +35,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const { t } = useTranslation();
const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile();
const navigate = useNavigate();
@@ -333,7 +335,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
ref={closeButtonRef}
variant="subtle"
onClick={handleClose}
aria-label="Close"
aria-label={t("settings.close", "Close")}
data-autofocus
>
<LocalIcon icon="close-rounded" width={18} height={18} />
@@ -8,6 +8,7 @@ import {
Group,
TextInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import SearchIcon from "@mui/icons-material/Search";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -71,6 +72,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
withinPortal = true,
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
}) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const isMultiValue = Array.isArray(value);
@@ -184,7 +186,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
}}
>
<TextInput
placeholder="Search..."
placeholder={t("dropdownList.searchPlaceholder", "Search...")}
value={searchTerm}
onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
@@ -98,8 +98,11 @@ export default function EditableSecretField({
variant="light"
onClick={handleEdit}
disabled={disabled}
title="Edit"
aria-label="Edit secret value"
title={t("editableSecretField.edit", "Edit")}
aria-label={t(
"editableSecretField.editSecretValue",
"Edit secret value",
)}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
@@ -111,7 +111,7 @@ const FileCard = ({
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<Tooltip label={t("fileCard.viewInViewer", "View in Viewer")}>
<ActionIcon
size="sm"
variant="subtle"
@@ -126,7 +126,9 @@ const FileCard = ({
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<Tooltip
label={t("fileCard.openInFileEditor", "Open in File Editor")}
>
<ActionIcon
size="sm"
variant="subtle"
@@ -1,5 +1,6 @@
import React from "react";
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloseIcon from "@mui/icons-material/Close";
@@ -28,6 +29,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
switchingTo,
viewOptionStyle,
}) => {
const { t } = useTranslation();
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
@@ -95,7 +97,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
</Text>
)}
{onFileRemove && (
<Tooltip label="Close file" withArrow>
<Tooltip
label={t("fileDropdownMenu.closeFile", "Close file")}
withArrow
>
<ActionIcon
component="div"
size="xs"
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { FileId } from "@app/types/file";
@@ -141,6 +142,7 @@ export function FileItem({
onClick,
onEyeClick,
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
@@ -215,7 +217,11 @@ export function FileItem({
}}
tabIndex={-1}
type="button"
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
aria-label={
isViewedInViewer
? t("fileSidebar.fileItem.closeViewer", "Close viewer")
: t("fileSidebar.fileItem.openInViewer", "Open in viewer")
}
>
<VisibilityOutlinedIcon
className="file-sidebar-eye-open"
@@ -1,5 +1,6 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
@@ -85,6 +86,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
@@ -192,7 +194,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon
@@ -1,4 +1,5 @@
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import styles from "@app/components/shared/textInput/TextInput.module.css";
@@ -63,6 +64,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
},
ref,
) => {
const { t } = useTranslation();
const handleClear = () => {
if (onClear) {
onClear();
@@ -112,7 +114,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
type="button"
className={styles.clearButton}
onClick={handleClear}
aria-label="Clear input"
aria-label={t("textInput.clear", "Clear input")}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
@@ -6,6 +6,7 @@ import React, {
useCallback,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
@@ -68,6 +69,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
manualCloseOnly = false,
showCloseButton = false,
}) => {
const { t } = useTranslation();
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const { tooltipLogo } = useLogoAssets();
@@ -401,8 +403,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
setIsPinned(false);
setOpen(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
title={t("tooltip.close", "Close tooltip")}
aria-label={t("tooltip.close", "Close tooltip")}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
@@ -259,7 +259,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
onClick={onClose}
size="lg"
variant="subtle"
aria-label="Close update modal"
aria-label={t("update.closeModal", "Close update modal")}
/>
)}
</Group>
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { ActionIcon, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
@@ -9,6 +10,7 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut";
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
*/
export function ViewerInlineControls() {
const { t } = useTranslation();
const { workbench } = useNavigationState();
const viewer = useViewer();
@@ -39,7 +41,7 @@ export function ViewerInlineControls() {
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomOut()}
aria-label="Zoom out"
aria-label={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -68,7 +70,7 @@ export function ViewerInlineControls() {
radius="md"
className="workbench-bar-action-icon"
onClick={() => viewer.zoomActions.zoomIn()}
aria-label="Zoom in"
aria-label={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -15,6 +15,7 @@
import React, { useEffect, useRef, useState } from "react";
import { ActionIcon, Divider } from "@mantine/core";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import {
useNavigationState,
@@ -37,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
setActiveButton,
tooltipPosition = "right",
}) => {
const { t } = useTranslation();
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
@@ -158,7 +160,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
<div className="current-tool-content">
<div className="flex flex-col items-center gap-1">
<Tooltip
content={isBackHover ? "Back to all tools" : indicatorTool.name}
content={
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
position={tooltipPosition}
arrow
maxWidth={140}
@@ -183,7 +189,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
aria-label={
isBackHover ? "Back to all tools" : indicatorTool.name
isBackHover
? t("quickAccess.backToAllTools", "Back to all tools")
: indicatorTool.name
}
style={{
backgroundColor: isBackHover
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
import { LocalIcon } from "@app/components/shared/LocalIcon";
@@ -38,6 +39,7 @@ function getDefaultIconName(t: ToastInstance): string {
}
export default function ToastRenderer() {
const { t: translate } = useTranslation();
const { toasts, dismiss } = useToast();
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
@@ -88,7 +90,10 @@ export default function ToastRenderer() {
<div className="toast-controls">
{t.expandable && (
<button
aria-label="Toggle details"
aria-label={translate(
"toast.toggleDetails",
"Toggle details",
)}
onClick={() => {
const evt = new CustomEvent("toast:toggle", {
detail: { id: t.id },
@@ -101,7 +106,7 @@ export default function ToastRenderer() {
</button>
)}
<button
aria-label="Dismiss"
aria-label={translate("toast.dismiss", "Dismiss")}
onClick={() => dismiss(t.id)}
className="toast-button"
>
@@ -321,7 +321,7 @@ export default function AutomationEntry({
return shouldShowTooltip ? (
<Tooltip
content={createTooltipContent()}
position="right"
position="left"
arrow={true}
delay={500}
>
@@ -99,11 +99,14 @@ const CompressSettings = ({
{parameters.compressionMethod === "filesize" && (
<Stack gap="sm">
<Text size="sm" fw={500}>
Desired File Size
{t("compress.settings.desiredSize", "Desired File Size")}
</Text>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<NumberInput
placeholder="Enter size"
placeholder={t(
"compress.settings.desiredSizePlaceholder",
"Enter size",
)}
value={parameters.fileSizeValue}
onChange={(value) =>
onParameterChange("fileSizeValue", value?.toString() || "")
@@ -139,7 +139,9 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Loader size="xs" />
<Text size="sm">Loading available languages...</Text>
<Text size="sm">
{t("ocr.languagePicker.loading", "Loading available languages...")}
</Text>
</div>
);
}
@@ -75,7 +75,7 @@ export default function PageLayoutMarginsBordersSettings({
<Stack gap="sm">
<NumberInput
label={t("pageLayout.top", "Top Margin")}
placeholder="Enter top margin"
placeholder={t("pageLayout.margins.topPlaceholder", "Enter top margin")}
value={parameters.topMargin}
onChange={(v) => onParameterChange("topMargin", Number(v))}
min={0}
@@ -85,7 +85,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.bottom", "Bottom Margin")}
placeholder="Enter bottom margin"
placeholder={t(
"pageLayout.margins.bottomPlaceholder",
"Enter bottom margin",
)}
value={parameters.bottomMargin}
onChange={(v) => onParameterChange("bottomMargin", Number(v))}
min={0}
@@ -95,7 +98,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.left", "Left Margin")}
placeholder="Enter left margin"
placeholder={t(
"pageLayout.margins.leftPlaceholder",
"Enter left margin",
)}
value={parameters.leftMargin}
onChange={(v) => onParameterChange("leftMargin", Number(v))}
min={0}
@@ -104,7 +110,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.right", "Right Margin")}
placeholder="Enter right margin"
placeholder={t(
"pageLayout.margins.rightPlaceholder",
"Enter right margin",
)}
value={parameters.rightMargin}
onChange={(v) => onParameterChange("rightMargin", Number(v))}
min={0}
@@ -114,7 +123,10 @@ export default function PageLayoutMarginsBordersSettings({
/>
<NumberInput
label={t("pageLayout.innerMargin", "Inner Margin")}
placeholder="Enter inner margin"
placeholder={t(
"pageLayout.margins.innerPlaceholder",
"Enter inner margin",
)}
value={parameters.innerMargin}
onChange={(v) => onParameterChange("innerMargin", Number(v))}
min={0}
@@ -137,7 +149,10 @@ export default function PageLayoutMarginsBordersSettings({
{parameters.addBorder && (
<NumberInput
label={t("pageLayout.borderWidth", "Border Thickness")}
placeholder="Enter border thickness"
placeholder={t(
"pageLayout.margins.borderThicknessPlaceholder",
"Enter border thickness",
)}
value={parameters.borderWidth}
onChange={(v) => onParameterChange("borderWidth", Number(v))}
min={1}
@@ -80,7 +80,7 @@ export default function PageLayoutSettings({
<>
<NumberInput
label={t("pageLayout.rows", "Rows")}
placeholder="Enter rows"
placeholder={t("pageLayout.rowsPlaceholder", "Enter rows")}
value={parameters.rows}
onChange={(v) => onParameterChange("rows", Number(v))}
min={1}
@@ -90,7 +90,7 @@ export default function PageLayoutSettings({
<NumberInput
label={t("pageLayout.cols", "Columns")}
placeholder="Enter columns"
placeholder={t("pageLayout.colsPlaceholder", "Enter columns")}
value={parameters.cols}
onChange={(v) => onParameterChange("cols", Number(v))}
min={1}
@@ -353,7 +353,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
@@ -480,7 +480,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
@@ -618,7 +618,7 @@ export const SavedSignaturesSection = ({
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
aria-label={t("sign.saved.use", "Use signature")}
onClick={() =>
onUseSignature(activeLocalStorageSignature)
}
@@ -32,16 +32,16 @@ export const usePageSelectionTips = (): TooltipContent => {
),
bullets: [
t(
"bulkSelection.operators.and",
'AND: & or "and" require both conditions (e.g., 1-50 & even)',
"bulkSelection.operators.descriptions.and",
'AND: & or "and" - require both conditions (e.g., 1-50 & even)',
),
t(
"bulkSelection.operators.comma",
"Comma: , or | combine selections (e.g., 1-10, 20)",
"bulkSelection.operators.descriptions.comma",
"Comma: , or | - combine selections (e.g., 1-10, 20)",
),
t(
"bulkSelection.operators.not",
'NOT: ! or "not" exclude pages (e.g., 3n & not 30)',
"bulkSelection.operators.descriptions.not",
'NOT: ! or "not" - exclude pages (e.g., 3n & not 30)',
),
],
},
@@ -1176,7 +1176,12 @@ const EmbedPdfViewerContent = ({
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
<Text c="red">
{t(
"viewer.error.noFileProvided",
"Error: No file provided to viewer",
)}
</Text>
</Center>
) : isCurrentFileEncrypted ? (
<Center style={{ flex: 1 }}>
@@ -1,4 +1,5 @@
import React, { useCallback, useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useDocumentState } from "@embedpdf/core/react";
import { useScroll } from "@embedpdf/plugin-scroll/react";
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
@@ -143,6 +144,7 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
onMouseEnter,
onMouseLeave,
}) => {
const { t } = useTranslation();
const centerX =
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
scale;
@@ -170,8 +172,8 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
e.stopPropagation();
onDelete(annotationLink);
}}
aria-label="Delete link"
title="Delete link"
aria-label={t("viewer.link.delete", "Delete link")}
title={t("viewer.link.delete", "Delete link")}
>
<TrashIcon />
</button>
@@ -6,6 +6,7 @@ import {
forwardRef,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import { createPluginRegistration } from "@embedpdf/core";
import type { PluginRegistry } from "@embedpdf/core";
import { EmbedPDF } from "@embedpdf/core/react";
@@ -157,6 +158,7 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
},
ref,
) => {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const annotationApiRef = useRef<any>(null);
const zoomApiRef = useRef<any>(null);
@@ -653,7 +655,10 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
),
);
}}
aria-label="Delete signature"
aria-label={t(
"viewer.signature.delete",
"Delete signature",
)}
>
<CloseIcon
style={{ fontSize: "0.8rem" }}
@@ -139,7 +139,7 @@ function RedactionSelectionMenuInner({
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
<Tooltip label="Remove this mark">
<Tooltip label={t("viewer.redaction.removeMark", "Remove this mark")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -203,7 +203,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
aria-label={t("viewer.search.close", "Close search")}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
@@ -227,7 +227,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
aria-label={t("viewer.search.clear", "Clear search")}
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
@@ -267,7 +267,9 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={!resultInfo || resultInfo.totalResults === 0}
/>
<Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0}
{t("viewer.search.resultsOf", "of {{total}}", {
total: resultInfo?.totalResults || 0,
})}
</Text>
</Group>
@@ -277,7 +279,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
size="sm"
onClick={handlePrevious}
disabled={!resultInfo || resultInfo.currentIndex <= 1}
aria-label="Previous result"
aria-label={t("viewer.search.previous", "Previous result")}
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
@@ -288,7 +290,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
disabled={
!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults
}
aria-label="Next result"
aria-label={t("viewer.search.next", "Next result")}
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
@@ -34,7 +34,7 @@ interface FormFieldSidebarProps {
}
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
useTranslation();
const { t } = useTranslation();
const { state, setValue, setActiveField } = useFormFill();
const { fields, activeFieldName, loading } = state;
const activeFieldRef = useRef<HTMLDivElement>(null);
@@ -119,7 +119,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
variant="subtle"
size="sm"
onClick={onToggle}
aria-label="Close sidebar"
aria-label={t("formFill.sidebar.close", "Close sidebar")}
>
<CloseIcon sx={{ fontSize: 16 }} />
</ActionIcon>
@@ -27,6 +27,7 @@ import {
Tooltip,
ActionIcon,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
useFormFill,
useAllFormValues,
@@ -120,6 +121,7 @@ const _MODE_TABS: ModeTabDef[] = [
// ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool } = useNavigation();
const { selectors, state: fileState } = useFileState();
@@ -488,7 +490,10 @@ const FormFill = (_props: BaseToolProps) => {
{/* Flatten toggle */}
<Switch
label="Flatten after filling"
label={t(
"formFill.flattenAfterFilling",
"Flatten after filling",
)}
checked={flatten}
onChange={(e) => setFlatten(e.currentTarget.checked)}
size="xs"
@@ -507,15 +512,22 @@ const FormFill = (_props: BaseToolProps) => {
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
>
Save
{t("formFill.save", "Save")}
</Button>
<Tooltip label="Re-scan fields" withArrow position="bottom">
<Tooltip
label={t("formFill.rescanFields", "Re-scan fields")}
withArrow
position="bottom"
>
<ActionIcon
variant="light"
size="md"
onClick={handleRefresh}
aria-label="Re-scan form fields"
aria-label={t(
"formFill.rescanFormFields",
"Re-scan form fields",
)}
>
<RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon>
@@ -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];