mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
## 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
458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
ReactNode,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import licenseService, {
|
|
PlanTierGroup,
|
|
LicenseInfo,
|
|
mapLicenseToTier,
|
|
PlanTier,
|
|
} from "@app/services/licenseService";
|
|
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
|
|
import { userManagementService } from "@app/services/userManagementService";
|
|
import { alert } from "@app/components/toast";
|
|
import {
|
|
pollLicenseKeyWithBackoff,
|
|
activateLicenseKey,
|
|
resyncExistingLicense,
|
|
} from "@app/utils/licenseCheckoutUtils";
|
|
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
|
|
currency?: string; // Optional currency override (auto-detected from locale)
|
|
onSuccess?: (sessionId: string) => void; // Callback after successful payment
|
|
onError?: (error: string) => void; // Callback on error
|
|
}
|
|
|
|
interface CheckoutContextValue {
|
|
openCheckout: (
|
|
tier: "server" | "enterprise",
|
|
options?: CheckoutOptions,
|
|
) => Promise<void>;
|
|
closeCheckout: () => void;
|
|
isOpen: boolean;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
const CheckoutContext = createContext<CheckoutContextValue | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
interface CheckoutProviderProps {
|
|
children: ReactNode;
|
|
defaultCurrency?: string;
|
|
}
|
|
|
|
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
|
children,
|
|
defaultCurrency,
|
|
}) => {
|
|
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] =
|
|
useState<PlanTierGroup | null>(null);
|
|
const [minimumSeats, setMinimumSeats] = useState<number>(1);
|
|
const [currentCurrency, setCurrentCurrency] = useState(() => {
|
|
// Use provided default or auto-detect from locale
|
|
return defaultCurrency || getPreferredCurrency(i18n.language);
|
|
});
|
|
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
|
|
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
|
|
isUpgrade: boolean;
|
|
licenseKey?: string;
|
|
} | null>(null);
|
|
|
|
// Lazy-loaded plans state (no fetch on mount)
|
|
const [plans, setPlans] = useState<PlanTier[]>([]);
|
|
const [plansLoaded, setPlansLoaded] = useState(false);
|
|
const [plansLoading, setPlansLoading] = useState(false);
|
|
|
|
// Lazy fetch plans only when needed
|
|
const fetchPlansIfNeeded = useCallback(
|
|
async (currency: string) => {
|
|
// Don't fetch if already loading
|
|
if (plansLoading) return;
|
|
|
|
try {
|
|
setPlansLoading(true);
|
|
const response = await licenseService.getPlans(
|
|
planFeatures,
|
|
planHighlights,
|
|
currency,
|
|
);
|
|
setPlans(response.plans);
|
|
setPlansLoaded(true);
|
|
} catch (error) {
|
|
console.error("Failed to fetch plans:", error);
|
|
// Don't block - let components handle the error
|
|
} finally {
|
|
setPlansLoading(false);
|
|
}
|
|
},
|
|
[plansLoading, planFeatures, planHighlights],
|
|
);
|
|
|
|
const refetchPlans = useCallback(() => {
|
|
setPlansLoaded(false); // Force refetch
|
|
return fetchPlansIfNeeded(currentCurrency);
|
|
}, [currentCurrency, fetchPlansIfNeeded]);
|
|
|
|
// Handle return from hosted Stripe checkout
|
|
useEffect(() => {
|
|
const handleCheckoutReturn = async () => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const paymentStatus = urlParams.get("payment_status");
|
|
const sessionId = urlParams.get("session_id");
|
|
|
|
if (paymentStatus === "success" && sessionId) {
|
|
console.log("Payment successful via hosted checkout:", sessionId);
|
|
|
|
// Clear URL parameters
|
|
window.history.replaceState({}, "", window.location.pathname);
|
|
|
|
// Fetch current license info to determine upgrade vs new
|
|
let licenseInfo: LicenseInfo | null = null;
|
|
try {
|
|
licenseInfo = await licenseService.getLicenseInfo();
|
|
} catch (err) {
|
|
console.warn("Could not fetch license info:", err);
|
|
}
|
|
|
|
// Check if this is an upgrade or new subscription
|
|
// Only treat as upgrade if there's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
|
|
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
|
|
// UPGRADE: Resync existing license with Keygen
|
|
console.log("Upgrade detected - resyncing existing license");
|
|
|
|
const activation = await resyncExistingLicense();
|
|
|
|
if (activation.success) {
|
|
console.log(
|
|
"License synced successfully, refreshing license context",
|
|
);
|
|
|
|
// Ensure plans are loaded before using them
|
|
if (!plansLoaded) {
|
|
await fetchPlansIfNeeded(currentCurrency);
|
|
}
|
|
|
|
// Refresh global license context
|
|
await refetchLicense();
|
|
await refetchPlans();
|
|
|
|
// Determine tier from license type
|
|
const tier =
|
|
activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
|
|
const planGroups = licenseService.groupPlansByTier(plans);
|
|
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
|
|
|
if (planGroup) {
|
|
// Reopen modal to show success
|
|
setSelectedPlanGroup(planGroup);
|
|
setHostedCheckoutSuccess({ isUpgrade: true });
|
|
setIsOpen(true);
|
|
} else {
|
|
// Fallback to toast if plan group not found
|
|
alert({
|
|
alertType: "success",
|
|
title: t("payment.upgradeSuccess"),
|
|
});
|
|
}
|
|
} else {
|
|
console.error(
|
|
"Failed to sync license after upgrade:",
|
|
activation.error,
|
|
);
|
|
alert({
|
|
alertType: "error",
|
|
title: t("payment.syncError"),
|
|
});
|
|
}
|
|
} else {
|
|
// NEW SUBSCRIPTION: Poll for license key
|
|
console.log("New subscription - polling for license key");
|
|
|
|
try {
|
|
const installationId = await licenseService.getInstallationId();
|
|
console.log(
|
|
"Polling for license key with installation ID:",
|
|
installationId,
|
|
);
|
|
|
|
// Use shared polling utility
|
|
const result = await pollLicenseKeyWithBackoff(installationId);
|
|
|
|
if (result.success && result.licenseKey) {
|
|
// Activate the license key
|
|
const activation = await activateLicenseKey(result.licenseKey);
|
|
|
|
if (activation.success) {
|
|
console.log(`License key activated: ${activation.licenseType}`);
|
|
|
|
// Ensure plans are loaded before using them
|
|
if (!plansLoaded) {
|
|
await fetchPlansIfNeeded(currentCurrency);
|
|
}
|
|
|
|
// Refresh global license context
|
|
await refetchLicense();
|
|
await refetchPlans();
|
|
|
|
// Determine tier from license type
|
|
const tier =
|
|
activation.licenseType === "ENTERPRISE"
|
|
? "enterprise"
|
|
: "server";
|
|
const planGroups = licenseService.groupPlansByTier(plans);
|
|
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
|
|
|
if (planGroup) {
|
|
// Reopen modal to show success with license key
|
|
setSelectedPlanGroup(planGroup);
|
|
setHostedCheckoutSuccess({
|
|
isUpgrade: false,
|
|
licenseKey: result.licenseKey,
|
|
});
|
|
setIsOpen(true);
|
|
} else {
|
|
// Fallback to toast if plan group not found
|
|
alert({
|
|
alertType: "success",
|
|
title: t("payment.licenseActivated"),
|
|
});
|
|
}
|
|
} else {
|
|
console.error("Failed to save license key:", activation.error);
|
|
alert({
|
|
alertType: "error",
|
|
title: t("payment.licenseSaveError"),
|
|
});
|
|
}
|
|
} else if (result.timedOut) {
|
|
console.warn("License key polling timed out");
|
|
alert({
|
|
alertType: "warning",
|
|
title: t("payment.licenseDelayed"),
|
|
});
|
|
} else {
|
|
console.error("License key polling failed:", result.error);
|
|
alert({
|
|
alertType: "error",
|
|
title: t("payment.licensePollingError"),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to poll for license key:", error);
|
|
alert({
|
|
alertType: "error",
|
|
title: t("payment.licenseRetrievalError"),
|
|
});
|
|
}
|
|
}
|
|
} else if (paymentStatus === "canceled") {
|
|
console.log("Payment canceled by user");
|
|
|
|
// Clear URL parameters
|
|
window.history.replaceState({}, "", window.location.pathname);
|
|
|
|
alert({
|
|
alertType: "warning",
|
|
title: t("payment.paymentCanceled"),
|
|
});
|
|
}
|
|
};
|
|
|
|
handleCheckoutReturn();
|
|
}, [
|
|
t,
|
|
refetchPlans,
|
|
refetchLicense,
|
|
plans,
|
|
fetchPlansIfNeeded,
|
|
plansLoaded,
|
|
currentCurrency,
|
|
]);
|
|
|
|
const openCheckout = useCallback(
|
|
async (tier: "server" | "enterprise", options: CheckoutOptions = {}) => {
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
// Check if Supabase is configured
|
|
if (!isSupabaseConfigured) {
|
|
throw new Error(
|
|
"Checkout is not available. Supabase is not configured.",
|
|
);
|
|
}
|
|
|
|
// Update currency if provided
|
|
const currency = options.currency || currentCurrency;
|
|
if (currency !== currentCurrency) {
|
|
setCurrentCurrency(currency);
|
|
}
|
|
|
|
// Fetch plans if not already loaded
|
|
if (!plansLoaded) {
|
|
await fetchPlansIfNeeded(currency);
|
|
}
|
|
|
|
// Fetch license info and user data for seat calculations
|
|
let licenseInfo: LicenseInfo | null = null;
|
|
let totalUsers = 0;
|
|
|
|
try {
|
|
const [licenseData, userData] = await Promise.all([
|
|
licenseService.getLicenseInfo(),
|
|
userManagementService.getUsers(),
|
|
]);
|
|
|
|
licenseInfo = licenseData;
|
|
totalUsers = userData.totalUsers || 0;
|
|
} catch (err) {
|
|
console.warn(
|
|
"Could not fetch license/user info, proceeding with defaults:",
|
|
err,
|
|
);
|
|
}
|
|
|
|
// Calculate minimum seats for enterprise upgrades
|
|
let calculatedMinSeats = options.minimumSeats || 1;
|
|
|
|
if (tier === "enterprise" && !options.minimumSeats) {
|
|
const currentTier = mapLicenseToTier(licenseInfo);
|
|
|
|
if (currentTier === "server" || currentTier === "free") {
|
|
// Upgrading from Server (unlimited) to Enterprise (per-seat)
|
|
// Use current total user count as minimum
|
|
calculatedMinSeats = Math.max(totalUsers, 1);
|
|
console.log(
|
|
`Setting minimum seats from server user count: ${calculatedMinSeats}`,
|
|
);
|
|
} else if (currentTier === "enterprise") {
|
|
// Upgrading within Enterprise (e.g., monthly to yearly)
|
|
// Use current licensed seat count as minimum
|
|
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
|
|
console.log(
|
|
`Setting minimum seats from current license: ${calculatedMinSeats}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Find the plan group for the requested tier
|
|
const planGroups = licenseService.groupPlansByTier(plans);
|
|
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
|
|
|
if (!planGroup) {
|
|
throw new Error(`No ${tier} plan available`);
|
|
}
|
|
|
|
// Store options for callbacks
|
|
setCurrentOptions(options);
|
|
setMinimumSeats(calculatedMinSeats);
|
|
setSelectedPlanGroup(planGroup);
|
|
setIsOpen(true);
|
|
} catch (err) {
|
|
const errorMessage =
|
|
err instanceof Error ? err.message : "Failed to open checkout";
|
|
console.error("Error opening checkout:", errorMessage);
|
|
options.onError?.(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded],
|
|
);
|
|
|
|
const closeCheckout = useCallback(() => {
|
|
setIsOpen(false);
|
|
setSelectedPlanGroup(null);
|
|
setCurrentOptions({});
|
|
setHostedCheckoutSuccess(null);
|
|
|
|
// Refetch plans and license after modal closes to update subscription display
|
|
refetchPlans();
|
|
refetchLicense();
|
|
}, [refetchPlans, refetchLicense]);
|
|
|
|
const handlePaymentSuccess = useCallback(
|
|
(sessionId: string) => {
|
|
console.log("Payment successful, session:", sessionId);
|
|
currentOptions.onSuccess?.(sessionId);
|
|
// Don't close modal - let user view license key and close manually
|
|
},
|
|
[currentOptions],
|
|
);
|
|
|
|
const handlePaymentError = useCallback(
|
|
(error: string) => {
|
|
console.error("Payment error:", error);
|
|
currentOptions.onError?.(error);
|
|
},
|
|
[currentOptions],
|
|
);
|
|
|
|
const handleLicenseActivated = useCallback(
|
|
(licenseInfo: {
|
|
licenseType: string;
|
|
enabled: boolean;
|
|
maxUsers: number;
|
|
hasKey: boolean;
|
|
}) => {
|
|
console.log("License activated:", licenseInfo);
|
|
// Could expose this via context if needed
|
|
},
|
|
[],
|
|
);
|
|
|
|
const contextValue: CheckoutContextValue = {
|
|
openCheckout,
|
|
closeCheckout,
|
|
isOpen,
|
|
isLoading,
|
|
};
|
|
|
|
return (
|
|
<CheckoutContext.Provider value={contextValue}>
|
|
{children}
|
|
|
|
{/* Global Checkout Modal */}
|
|
{selectedPlanGroup && (
|
|
<StripeCheckout
|
|
opened={isOpen}
|
|
onClose={closeCheckout}
|
|
planGroup={selectedPlanGroup}
|
|
minimumSeats={minimumSeats}
|
|
onSuccess={handlePaymentSuccess}
|
|
onError={handlePaymentError}
|
|
onLicenseActivated={handleLicenseActivated}
|
|
hostedCheckoutSuccess={hostedCheckoutSuccess}
|
|
/>
|
|
)}
|
|
</CheckoutContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useCheckout = (): CheckoutContextValue => {
|
|
const context = useContext(CheckoutContext);
|
|
if (!context) {
|
|
throw new Error("useCheckout must be used within CheckoutProvider");
|
|
}
|
|
return context;
|
|
};
|