mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 04:08:22 +02:00
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
574 lines
19 KiB
TypeScript
574 lines
19 KiB
TypeScript
import {
|
|
Modal,
|
|
Stack,
|
|
Card,
|
|
Text,
|
|
Group,
|
|
Badge,
|
|
Button,
|
|
Alert,
|
|
} from "@mantine/core";
|
|
import { useTranslation } from "react-i18next";
|
|
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
|
|
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
|
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
|
import {
|
|
BILLING_CONFIG,
|
|
getCurrencySymbol,
|
|
getFormattedOveragePrice,
|
|
} from "@app/config/billing";
|
|
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
|
import { CreditUsageBanner } from "@app/components/shared/modals/CreditUsageBanner";
|
|
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
|
|
import {
|
|
FREE_PLAN_FEATURES,
|
|
TEAM_PLAN_FEATURES,
|
|
ENTERPRISE_PLAN_FEATURES,
|
|
} from "@app/config/planFeatures";
|
|
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
|
|
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
|
|
|
|
interface CreditExhaustedModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* Desktop Credit Exhausted Modal
|
|
* Shows upgrade options when user runs out of credits
|
|
* Routes to different UI based on user status (free/team/managed member)
|
|
*/
|
|
export function CreditExhaustedModal({
|
|
opened,
|
|
onClose,
|
|
}: CreditExhaustedModalProps) {
|
|
const { t } = useTranslation();
|
|
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } =
|
|
useSaaSBilling();
|
|
const { isTeamLeader } = useSaaSTeam();
|
|
const { openCheckout } = useSaaSCheckout();
|
|
|
|
const { enablingMetering, meteringError, handleEnableMetering } =
|
|
useEnableMeteredBilling(refreshBilling, onClose, "CreditExhaustedModal");
|
|
|
|
// Managed team members have unlimited credits via team
|
|
if (isManagedTeamMember) {
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
withCloseButton
|
|
centered
|
|
size="md"
|
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
|
title={t("credits.modal.managedMemberTitle", "Unlimited Credits")}
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
{t(
|
|
"credits.modal.managedMemberMessage",
|
|
"You have unlimited access to credits through your team. If you need assistance, please contact your team leader.",
|
|
)}
|
|
</Text>
|
|
<Button onClick={onClose} fullWidth>
|
|
{t("common.close", "Close")}
|
|
</Button>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Team users should enable overage billing
|
|
// Only team leaders can enable metered billing, members see different UI
|
|
if (tier === "team") {
|
|
const teamPlan = plans.get("team");
|
|
const teamCurrency = teamPlan?.currency ?? "$";
|
|
const overagePrice =
|
|
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
|
const formattedOveragePrice = getFormattedOveragePrice(
|
|
teamCurrency,
|
|
overagePrice,
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
withCloseButton
|
|
closeOnClickOutside={!enablingMetering}
|
|
closeOnEscape={!enablingMetering}
|
|
centered
|
|
size="lg"
|
|
radius="md"
|
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
|
title={
|
|
<Stack gap="sm">
|
|
<Text size="lg" fw={450}>
|
|
{t(
|
|
"credits.modal.titleExhaustedPro",
|
|
"You have run out of credits",
|
|
)}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"credits.modal.subtitlePro",
|
|
"Enable automatic overage billing to never run out of credits.",
|
|
)}
|
|
</Text>
|
|
</Stack>
|
|
}
|
|
styles={{
|
|
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
|
content: {
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
},
|
|
header: {
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
},
|
|
overlay: {
|
|
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
|
|
},
|
|
}}
|
|
>
|
|
<Stack gap="lg">
|
|
<CreditUsageBanner
|
|
currentCredits={creditBalance}
|
|
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
|
|
/>
|
|
|
|
{meteringError && (
|
|
<Alert color="red" ml="lg" mr="lg">
|
|
{meteringError}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Explanation Card */}
|
|
<Card
|
|
padding="xl"
|
|
radius="md"
|
|
withBorder
|
|
ml="lg"
|
|
mr="lg"
|
|
style={{
|
|
borderColor: "var(--color-primary-600)",
|
|
borderWidth: 2,
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
}}
|
|
>
|
|
<Stack gap="md">
|
|
<Group gap="xs" align="center">
|
|
<TrendingUpIcon
|
|
sx={{ fontSize: 24, color: "var(--color-primary-600)" }}
|
|
/>
|
|
<Text size="lg" fw={600}>
|
|
{t(
|
|
"credits.modal.meteringTitle",
|
|
"Pay-What-You-Use Overage Billing",
|
|
)}
|
|
</Text>
|
|
</Group>
|
|
|
|
<Text size="sm" c="dimmed">
|
|
{t("credits.modal.meteringExplanation", {
|
|
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
|
defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`,
|
|
})}
|
|
</Text>
|
|
|
|
<Stack gap="xs">
|
|
<FeatureListItem included>
|
|
{t("credits.modal.meteringIncluded", {
|
|
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
|
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
|
|
})}
|
|
</FeatureListItem>
|
|
<FeatureListItem included>
|
|
{t(
|
|
"credits.modal.meteringPrice",
|
|
"Additional credits at {{price}}/credit",
|
|
{
|
|
price: formattedOveragePrice,
|
|
},
|
|
)}
|
|
</FeatureListItem>
|
|
<FeatureListItem included>
|
|
{t(
|
|
"credits.modal.meteringPayAsYouGo",
|
|
"Only pay for what you use",
|
|
)}
|
|
</FeatureListItem>
|
|
<FeatureListItem included>
|
|
{t(
|
|
"credits.modal.meteringNoCommitment",
|
|
"No commitment, cancel anytime",
|
|
)}
|
|
</FeatureListItem>
|
|
<FeatureListItem included>
|
|
{t(
|
|
"credits.modal.meteringNeverRunOut",
|
|
"Never run out of credits",
|
|
)}
|
|
</FeatureListItem>
|
|
</Stack>
|
|
|
|
<Card
|
|
padding="md"
|
|
radius="md"
|
|
style={{
|
|
backgroundColor: "light-dark(#F8F9FA, #1A1A1E)",
|
|
border: "1px solid var(--border-subtle)",
|
|
}}
|
|
>
|
|
<Text size="xs" c="dimmed">
|
|
{t(
|
|
"credits.modal.meteringBillingNote",
|
|
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
|
|
)}
|
|
</Text>
|
|
</Card>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Action Buttons */}
|
|
<Stack gap="sm" ml="lg" mr="lg">
|
|
<Button
|
|
onClick={handleEnableMetering}
|
|
variant="filled"
|
|
color="blue"
|
|
fullWidth
|
|
size="lg"
|
|
loading={enablingMetering}
|
|
disabled={!isTeamLeader}
|
|
leftSection={<TrendingUpIcon sx={{ fontSize: 18 }} />}
|
|
style={{
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{t("credits.enableOverageBilling", "Enable Overage Billing")}
|
|
</Button>
|
|
{!isTeamLeader && (
|
|
<Text size="xs" c="dimmed" ta="center">
|
|
{t(
|
|
"credits.modal.teamLeaderOnly",
|
|
"Only team leaders can enable overage billing",
|
|
)}
|
|
</Text>
|
|
)}
|
|
<Button
|
|
onClick={onClose}
|
|
variant="subtle"
|
|
fullWidth
|
|
size="md"
|
|
c="dimmed"
|
|
disabled={enablingMetering}
|
|
>
|
|
{t("credits.maybeLater", "Maybe later")}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Free tier users - show upgrade modal
|
|
const teamPlan = plans.get("team");
|
|
const teamPrice = teamPlan?.price ?? 20;
|
|
const teamCurrency = teamPlan?.currency ?? "$";
|
|
const overagePrice =
|
|
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
|
|
|
const currencySymbol = getCurrencySymbol(teamCurrency);
|
|
const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`;
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
withCloseButton
|
|
closeOnClickOutside
|
|
closeOnEscape
|
|
centered
|
|
size="60rem"
|
|
radius="md"
|
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
|
title={
|
|
<Stack gap="sm">
|
|
<Text size="lg" fw={450}>
|
|
{t("credits.modal.titleExhausted", "You've used your free credits")}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"credits.modal.subtitle",
|
|
"Upgrade to Team for 10x the credits and faster processing.",
|
|
)}
|
|
</Text>
|
|
</Stack>
|
|
}
|
|
styles={{
|
|
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
|
content: {
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
},
|
|
header: {
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
},
|
|
overlay: {
|
|
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
|
|
},
|
|
}}
|
|
>
|
|
<Stack gap="lg">
|
|
<CreditUsageBanner
|
|
currentCredits={creditBalance}
|
|
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
|
|
/>
|
|
|
|
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
|
|
{/* Free Plan Card */}
|
|
<Card
|
|
padding="xl"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
borderColor: "var(--border-default)",
|
|
borderWidth: 1,
|
|
opacity: 0.85,
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
}}
|
|
>
|
|
<Stack gap="md" style={{ height: "100%" }}>
|
|
<div>
|
|
<Text size="lg" fw={600} mb="xs">
|
|
{t("credits.modal.freeTier", "Free Tier")}
|
|
</Text>
|
|
<Group gap="xs" align="baseline">
|
|
<Text size="1.75rem" fw={700}>
|
|
{currencySymbol}0
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t("credits.modal.perMonth", "/month")}
|
|
</Text>
|
|
</Group>
|
|
<Text size="sm" c="dimmed" mt="xs">
|
|
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH}{" "}
|
|
{t("credits.modal.monthlyCredits", "monthly credits")}
|
|
</Text>
|
|
</div>
|
|
|
|
<Stack gap="xs" style={{ flex: 1 }}>
|
|
<Text size="sm" fw={500} mb="xs">
|
|
{t("credits.modal.forRegularWork", "For regular PDF work:")}
|
|
</Text>
|
|
{FREE_PLAN_FEATURES.map((feature, index) => (
|
|
<FeatureListItem
|
|
key={index}
|
|
included
|
|
color="var(--mantine-color-gray-6)"
|
|
>
|
|
{t(feature.translationKey, feature.defaultText)}
|
|
</FeatureListItem>
|
|
))}
|
|
</Stack>
|
|
|
|
<Button
|
|
disabled
|
|
variant="subtle"
|
|
fullWidth
|
|
size="md"
|
|
radius="lg"
|
|
style={{
|
|
fontWeight: 600,
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "default",
|
|
}}
|
|
>
|
|
{t("credits.modal.current", "Current Plan")}
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Team Plan Card */}
|
|
<Card
|
|
padding="lg"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
borderColor: "var(--card-selected-border)",
|
|
borderWidth: 2,
|
|
cursor: "pointer",
|
|
transition: "all 0.2s ease",
|
|
position: "relative",
|
|
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
|
|
overflow: "visible",
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
}}
|
|
onClick={() => openCheckout("pro")}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.transform = "translateY(-4px)";
|
|
e.currentTarget.style.boxShadow =
|
|
"0 12px 48px rgba(59, 130, 246, 0.3)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.transform = "translateY(0)";
|
|
e.currentTarget.style.boxShadow =
|
|
"0 2px 8px rgba(59, 130, 246, 0.1)";
|
|
}}
|
|
>
|
|
<Badge
|
|
size="sm"
|
|
style={{
|
|
position: "absolute",
|
|
top: -10,
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
background: "rgb(59, 130, 246)",
|
|
color: "white",
|
|
fontWeight: 600,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
paddingLeft: "12px",
|
|
paddingRight: "12px",
|
|
}}
|
|
>
|
|
{t("credits.modal.popular", "Popular")}
|
|
</Badge>
|
|
<Stack gap="md" style={{ height: "100%" }}>
|
|
<div>
|
|
<Text size="lg" fw={600} mb="xs">
|
|
{t("credits.modal.teamSubscription", "Team")}
|
|
</Text>
|
|
<Group gap="xs" align="baseline" mt="xs">
|
|
<Text
|
|
size="1.75rem"
|
|
fw={700}
|
|
style={{ color: "var(--text-primary)" }}
|
|
>
|
|
{currencySymbol}
|
|
{teamPrice}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t("credits.modal.perMonth", "/month")}
|
|
</Text>
|
|
</Group>
|
|
<Text size="sm" c="dimmed" mt="xs">
|
|
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}{" "}
|
|
{t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
|
|
{formattedOveragePrice}/
|
|
{t("credits.modal.overage", "overage")}
|
|
</Text>
|
|
</div>
|
|
|
|
<Stack gap="xs" style={{ flex: 1 }}>
|
|
<Text size="sm" fw={500} mb="xs">
|
|
{t(
|
|
"credits.modal.everythingInFree",
|
|
"Everything in Free, plus:",
|
|
)}
|
|
</Text>
|
|
{TEAM_PLAN_FEATURES.map((feature, index) => (
|
|
<FeatureListItem key={index} included>
|
|
{t(feature.translationKey, feature.defaultText)}
|
|
</FeatureListItem>
|
|
))}
|
|
</Stack>
|
|
|
|
<Button
|
|
onClick={() => openCheckout("pro")}
|
|
variant="filled"
|
|
color="blue"
|
|
fullWidth
|
|
size="md"
|
|
radius="lg"
|
|
style={{
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{t("credits.upgrade", "Upgrade")}
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Enterprise Plan Card */}
|
|
<Card
|
|
padding="lg"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
borderWidth: 1,
|
|
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
|
}}
|
|
>
|
|
<Stack gap="md" style={{ height: "100%" }}>
|
|
<div>
|
|
<Text size="md" fw={600} mb="xs">
|
|
{t("credits.modal.enterpriseSubscription", "Enterprise")}
|
|
</Text>
|
|
<Text size="1.75rem" fw={600}>
|
|
{t("credits.modal.customPricing", "Custom")}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" mt="xs">
|
|
{t("credits.modal.unlimitedMonthlyCredits", "Site License")}
|
|
</Text>
|
|
</div>
|
|
|
|
<Stack gap="xs" style={{ flex: 1 }}>
|
|
<Text size="sm" fw={500} mb="xs">
|
|
{t(
|
|
"credits.modal.everythingInCredits",
|
|
"Everything in Credits, plus:",
|
|
)}
|
|
</Text>
|
|
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
|
|
<FeatureListItem key={index} included>
|
|
{t(feature.translationKey, feature.defaultText)}
|
|
</FeatureListItem>
|
|
))}
|
|
</Stack>
|
|
|
|
<Button
|
|
component="a"
|
|
href="mailto:[email protected]?subject=Enterprise Plan Inquiry"
|
|
variant="outline"
|
|
fullWidth
|
|
size="md"
|
|
radius="lg"
|
|
style={{
|
|
borderColor: "var(--text-primary)",
|
|
color: "var(--text-primary)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{t("credits.modal.contactSales", "Contact Sales")}
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
</Group>
|
|
|
|
<Text size="sm" ta="center" c="dimmed" mt="md">
|
|
{t("credits.modal.selfHostPrompt", "Want to self host?")}{" "}
|
|
<Text
|
|
component="a"
|
|
href="https://www.stirling.com/pricing"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
size="sm"
|
|
style={{
|
|
color: "var(--mantine-color-blue-6)",
|
|
textDecoration: "none",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.textDecoration = "underline";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.textDecoration = "none";
|
|
}}
|
|
>
|
|
{t("credits.modal.selfHostLink", "Review the docs and plans")}
|
|
</Text>
|
|
</Text>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|