Compare commits

..
Author SHA1 Message Date
Anthony StirlingandGitHub 0638f8db8d Bump version from 2.1.2 to 2.1.3 2025-12-11 13:11:42 +00:00
Reece BrowneandGitHub f29d85565a Chore/v2/ctrlf (#5217)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-11 12:42:16 +00:00
Reece BrowneandGitHub ae72344317 Offline pdfium (#5213)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-11 11:23:20 +00:00
Reece BrowneandGitHub 6565a6ce18 Bug/v2/improved cache busting (#5107)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-11 11:19:55 +00:00
LudyandGitHub e26035c3b3 build(versioning): synchronize app version across Tauri and simulation configs (#5120)
# Description of Changes

- **What was changed**
- Added `groovy.json.JsonOutput` and `groovy.json.JsonSlurper` imports
to `build.gradle`.
- Introduced a reusable `writeIfChanged(File targetFile, String
newContent)` helper to avoid unnecessary file writes when content is
unchanged.
  - Added `updateTauriConfigVersion(String version)` to:
    - Parse `frontend/src-tauri/tauri.conf.json`.
    - Set the `version` field from `project.version`.
- Re-write the file as pretty-printed JSON (with a trailing line
separator) only if content actually changed.
- Added `updateSimulationVersion(File fileToUpdate, String version)` to:
- Locate the `appVersion: '<value>'` assignment via regex in simulation
files.
    - Replace the existing version with `project.version`.
- Fail the build with a clear `GradleException` if `appVersion` cannot
be found.
- Registered a new Gradle task `syncAppVersion` (group: `versioning`)
which:
    - Reads `project.version` as the canonical app version.
    - Updates `frontend/src-tauri/tauri.conf.json`.
- Updates `frontend/src/core/testing/serverExperienceSimulations.ts`.
- Updates
`frontend/src/proprietary/testing/serverExperienceSimulations.ts`.
- Updated the main `build` task so it now depends on `syncAppVersion` in
addition to `:stirling-pdf:bootJar` and `buildRestartHelper`.

- **Why the change was made**
- To ensure the desktop Tauri configuration and server experience
simulation configs consistently use the same application version as
defined in `project.version`.
- To remove manual version bumps in multiple files and eliminate the
risk of version mismatches between backend, desktop app, and
simulation/testing tooling.
- To minimize noise in commits and CI by only touching versioned files
when their content actually changes (using `writeIfChanged`).


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-11 11:13:54 +00:00
ConnorYohandGitHub e474cc76ad Improved static upgrade flow (#5214)
<img width="996" height="621" alt="image"
src="https://github.com/user-attachments/assets/1ac87414-09ed-4307-8f7c-25984e0c89d1"
/>
<img width="608" height="351" alt="image"
src="https://github.com/user-attachments/assets/c271f75e-4844-4034-8905-007cc7ab1265"
/>
<img width="660" height="355" alt="image"
src="https://github.com/user-attachments/assets/34913b74-d4fa-418a-b098-fda48b41f0dd"
/>
<img width="1371" height="906" alt="image"
src="https://github.com/user-attachments/assets/35b61389-fd67-41b3-9969-e5409e53b362"
/>
<img width="639" height="450" alt="image"
src="https://github.com/user-attachments/assets/ae018bf3-0fcf-4221-892f-440d7325540a"
/>
<img width="963" height="599" alt="image"
src="https://github.com/user-attachments/assets/f6f67682-f43c-46f3-8632-16b209780b15"
/>
<img width="982" height="628" alt="image"
src="https://github.com/user-attachments/assets/45a7c171-3eb4-4271-a299-f3a6e78c1a52"
/>
2025-12-11 11:13:20 +00:00
22 changed files with 1229 additions and 519 deletions
@@ -1,10 +1,14 @@
package stirling.software.SPDF.config;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
@@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.1.2'
version = '2.1.3'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+33
View File
@@ -105,6 +105,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
@@ -11093,6 +11094,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -14503,6 +14517,25 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-static-copy": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.3",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.15"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
+1
View File
@@ -152,6 +152,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
@@ -5569,6 +5569,28 @@ contactSales = "Contact Sales"
contactToUpgrade = "Contact us to upgrade or customize your plan"
maxUsers = "Max Users"
upTo = "Up to"
getLicense = "Get Server License"
upgradeToEnterprise = "Upgrade to Enterprise"
selectPeriod = "Select Billing Period"
monthlyBilling = "Monthly Billing"
yearlyBilling = "Yearly Billing"
checkoutOpened = "Checkout Opened"
checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key."
activateLicense = "Activate Your License"
[plan.static.licenseActivation]
checkoutOpened = "Checkout Opened in New Tab"
instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key."
enterKey = "Enter your license key below to activate your plan:"
keyDescription = "Paste the license key from your email"
activate = "Activate License"
doLater = "I'll do this later"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.static.billingPortal]
title = "Email Verification Required"
message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link."
[plan.period]
month = "month"
@@ -44,8 +44,10 @@ export function CustomSearchLayer({
}
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
if (!state) return;
// Auto-scroll to active search result
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
const activeResult = state.results[state.activeResultIndex];
if (activeResult) {
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
@@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions,
panActions: _panActions,
rotationActions: _rotationActions,
@@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({
onZoomOut: zoomActions.zoomOut,
});
// Handle keyboard zoom shortcuts
// Handle keyboard shortcuts (zoom and search)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;
@@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({
// Ctrl+- for zoom out
event.preventDefault();
zoomActions.zoomOut();
} else if (event.key === 'f' || event.key === 'F') {
// Ctrl+F for search
event.preventDefault();
if (isSearchInterfaceVisible) {
// If already open, trigger refocus event
window.dispatchEvent(new CustomEvent('refocus-search-input'));
} else {
// Open search interface
searchInterfaceActions.open();
}
}
}
};
@@ -207,7 +219,7 @@ const EmbedPdfViewerContent = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isViewerHovered]);
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {
@@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { absoluteWithBasePath } from '@app/constants/app';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -167,8 +168,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
];
}, [pdfUrl]);
// Initialize the engine with the React hook
const { engine, isLoading, error } = usePdfiumEngine();
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
wasmUrl: absoluteWithBasePath('/pdfium/pdfium.wasm'),
});
// Early return if no file or URL provided
@@ -28,11 +28,13 @@ export function SearchAPIBridge() {
if (!search) return;
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
if (!state) return;
const newState = {
results: state?.results || null,
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
results: state.results || null,
activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index
};
setLocalState(prevState => {
// Only update if state actually changed
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
@@ -52,16 +54,42 @@ export function SearchAPIBridge() {
state: localState,
api: {
search: async (query: string) => {
search.startSearch();
return search.searchAllPages(query);
if (search?.startSearch && search?.searchAllPages) {
search.startSearch();
return search.searchAllPages(query);
}
},
clear: () => {
search.stopSearch();
try {
if (search?.stopSearch) {
search.stopSearch();
}
} catch (error) {
console.warn('Error stopping search:', error);
}
setLocalState({ results: null, activeIndex: 0 });
},
next: () => search.nextResult(),
previous: () => search.previousResult(),
goToResult: (index: number) => search.goToResult(index),
next: () => {
try {
search?.nextResult?.();
} catch (error) {
console.warn('Error navigating to next result:', error);
}
},
previous: () => {
try {
search?.previousResult?.();
} catch (error) {
console.warn('Error navigating to previous result:', error);
}
},
goToResult: (index: number) => {
try {
search?.goToResult?.(index);
} catch (error) {
console.warn('Error going to result:', error);
}
},
}
});
}
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
@@ -12,7 +12,9 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchState = viewerContext?.getSearchState();
const searchResults = searchState?.results;
const searchActiveIndex = searchState?.activeIndex;
@@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
} | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Auto-focus search input when visible
useEffect(() => {
if (visible) {
inputRef.current?.focus();
}
}, [visible]);
// Listen for refocus event (when Ctrl+F pressed while already open)
useEffect(() => {
const handleRefocus = () => {
inputRef.current?.focus();
inputRef.current?.select();
};
window.addEventListener('refocus-search-input', handleRefocus);
return () => {
window.removeEventListener('refocus-search-input', handleRefocus);
};
}, []);
// Auto-search as user types (debounced)
useEffect(() => {
// Clear existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// If query is empty, clear search immediately
if (!searchQuery.trim()) {
searchActions?.clear();
setResultInfo(null);
return;
}
// Debounce search by 300ms
searchTimeoutRef.current = setTimeout(async () => {
if (searchQuery.trim() && searchActions) {
setIsSearching(true);
try {
await searchActions.search(searchQuery.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
}
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchActions]);
// Monitor search state changes
useEffect(() => {
if (!visible) return;
@@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
return () => clearInterval(interval);
}, [visible, searchResults, searchActiveIndex, searchQuery]);
const handleSearch = async (query: string) => {
if (!query.trim()) {
// If query is empty, clear the search
handleClearSearch();
return;
}
if (query.trim() && searchActions) {
setIsSearching(true);
try {
await searchActions.search(query.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch(searchQuery);
// Navigate to next result on Enter
event.preventDefault();
handleNext();
} else if (event.key === 'Escape') {
onClose();
} else if (event.key === 'ArrowDown') {
// Navigate to next result
event.preventDefault();
handleNext();
} else if (event.key === 'ArrowUp') {
// Navigate to previous result
event.preventDefault();
handlePrevious();
}
};
@@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
// No longer need to sync with external API on mount - removed
const handleJumpToResult = (index: number) => {
// Use context actions instead of window API - functionality simplified for now
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
console.log('Jump to result:', index);
// Convert to 0-based index for the API
searchActions?.goToResult?.(index - 1);
}
};
const handleJumpToSubmit = () => {
const index = parseInt(jumpToValue);
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
const index = parseInt(jumpToValue, 10);
if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
handleJumpToResult(index);
setJumpToValue(''); // Clear the input after jumping
}
};
@@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
}
};
const _handleClose = () => {
const handleInputBlur = () => {
// Close popover on blur if no text is entered
if (!searchQuery.trim()) {
onClose();
}
};
const handleCloseClick = () => {
handleClearSearch();
onClose();
};
@@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
padding: '0px'
}}
>
{/* Header */}
<Group mb="md">
{/* Header with close button */}
<Group mb="md" justify="space-between">
<Text size="sm" fw={600}>
{t('search.title', 'Search PDF')}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
{/* Search input */}
<Group mb="md">
<TextInput
ref={inputRef}
placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery}
onChange={(e) => {
const newValue = e.currentTarget.value;
setSearchQuery(newValue);
// If user clears the input, clear the search highlights
if (!newValue.trim()) {
handleClearSearch();
}
}}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
style={{ flex: 1 }}
rightSection={
<ActionIcon
variant="subtle"
onClick={() => handleSearch(searchQuery)}
disabled={!searchQuery.trim() || isSearching}
loading={isSearching}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
searchQuery.trim() && (
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
)
}
/>
</Group>
{/* Results info and navigation */}
{resultInfo && (
<Group justify="space-between" align="center">
{resultInfo.totalResults === 0 ? (
<Text size="sm" c="dimmed">
{t('search.noResults', 'No results found')}
</Text>
) : (
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => setJumpToValue(e.currentTarget.value)}
onKeyDown={handleJumpToKeyDown}
onBlur={handleJumpToSubmit}
placeholder={resultInfo.currentIndex.toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo.totalResults}
/>
<Text size="sm" c="dimmed">
of {resultInfo.totalResults}
</Text>
</Group>
)}
{resultInfo.totalResults > 0 && (
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={resultInfo.currentIndex <= 1}
aria-label="Previous result"
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
)}
{/* Results info and navigation - always show */}
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => {
const newValue = e.currentTarget.value;
setJumpToValue(newValue);
// Jump immediately as user types
const index = parseInt(newValue, 10);
if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) {
handleJumpToResult(index);
}
}}
onKeyDown={handleJumpToKeyDown}
onBlur={() => setJumpToValue('')} // Clear on blur instead of submit
placeholder={(resultInfo?.currentIndex || 0).toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo?.totalResults || 0}
disabled={!resultInfo || resultInfo.totalResults === 0}
/>
<Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0}
</Text>
</Group>
)}
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={!resultInfo || resultInfo.currentIndex <= 1}
aria-label="Previous result"
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
</Group>
</Group>
{/* Loading state */}
{isSearching && (
@@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
order: 10,
render: ({ disabled }) => (
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
opened={viewer.isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
className="right-rail-icon"
disabled={disabled}
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface visible={true} onClose={() => {}} />
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div>
</Popover.Dropdown>
</Popover>
@@ -80,6 +80,14 @@ interface ViewerContextType {
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
searchInterfaceActions: {
open: () => void;
close: () => void;
toggle: () => void;
};
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
@@ -145,6 +153,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
@@ -207,6 +216,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
toggle: () => setSearchInterfaceVisible(prev => !prev),
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@@ -294,6 +309,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,
@@ -52,6 +52,7 @@ export interface SearchActions {
next: () => void;
previous: () => void;
clear: () => void;
goToResult: (index: number) => void;
}
export interface ExportActions {
@@ -287,6 +288,12 @@ export function createViewerActions({
api.clear();
}
},
goToResult: (index: number) => {
const api = registry.current.search?.api;
if (api?.goToResult) {
api.goToResult(index);
}
},
};
const exportActions: ExportActions = {
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core';
import { Divider, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
@@ -7,30 +7,25 @@ import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const { licenseInfo } = useLicense();
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
@@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => {
}
}, [error]);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string (allow empty string to clear/remove license)
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage = inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
@@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => {
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
</Text>
<Text size="sm">
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
</Text>
<Text size="sm" fw={500}>
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{licenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: licenseInfo.licenseKey.substring(5)
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: licenseInfo.licenseType
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text'
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file'
}
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Existing text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB'
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};
@@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils';
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
// Check license tier match
if (currentTier && tierGroup.tier === currentTier) {
return true;
}
return false;
return checkIsCurrentTier(currentTier, tierGroup.tier);
};
// Determine if selecting this plan would be a downgrade
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
if (!currentTier) return false;
// Define tier hierarchy: enterprise > server > free
const tierHierarchy: Record<string, number> = {
'enterprise': 3,
'server': 2,
'free': 1
};
const currentLevel = tierHierarchy[currentTier] || 0;
const targetLevel = tierHierarchy[tierGroup.tier] || 0;
return currentLevel > targetLevel;
return checkIsDowngrade(currentTier, tierGroup.tier);
};
return (
@@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.5rem',
marginBottom: '0.1rem',
}}
>
{groupedPlans.map((group) => (
@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { LicenseInfo } from '@app/services/licenseService';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage =
inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
return (
<div>
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={showLicenseKey ? 'expand-less-rounded' : 'expand-more-rounded'}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Text size="sm">
{t(
'admin.settings.premium.licenseKey.info',
'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.'
)}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
'admin.settings.premium.key.overwriteWarning.line1',
'Overwriting your current license key cannot be undone.'
)}
</Text>
<Text size="sm">
{t(
'admin.settings.premium.key.overwriteWarning.line2',
'Your previous license will be permanently lost unless you have backed it up elsewhere.'
)}
</Text>
<Text size="sm" fw={500}>
{t(
'admin.settings.premium.key.overwriteWarning.line3',
'Important: Keep license keys private and secure. Never share them publicly.'
)}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: currentLicenseInfo.licenseKey.substring(5),
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: currentLicenseInfo.licenseType,
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text',
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file',
},
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'admin.settings.premium.key.description',
'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.'
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;
@@ -6,6 +6,7 @@ import { PricingBadge } from '@app/components/shared/stripeCheckout/components/P
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface PlanCardProps {
planGroup: PlanTierGroup;
@@ -83,7 +84,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const isEnterprise = planGroup.tier === 'enterprise';
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier);
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
@@ -174,7 +175,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
withArrow
>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
variant="filled"
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
@@ -0,0 +1,338 @@
import React, { useState } from 'react';
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { validateEmail } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from '@app/constants/staticStripeLinks';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useIsMobile } from '@app/hooks/useIsMobile';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: 'server' | 'enterprise';
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>('email');
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState('');
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError('');
setStageHistory([...stageHistory, 'email']);
setStage('period-selection');
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, '_blank');
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key'),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
});
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStageHistory([]);
setLicenseKey('');
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
return planName === 'server'
? t('plan.static.getLicense', 'Get Server License')
: t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
if (stage === 'period-selection') {
return t('plan.static.selectPeriod', 'Select Billing Period');
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
}
return '';
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case 'period-selection':
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case 'license-activation':
return (
<Stack gap="lg" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('plan.static.licenseActivation.checkoutOpened', 'Checkout Opened in New Tab')}
</Text>
<Text size="sm">
{t(
'plan.static.licenseActivation.instructions',
'Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.'
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
>
<Text size="sm">
{t(
'plan.static.licenseActivation.successMessage',
'Your license has been successfully activated. You can now close this window.'
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
'plan.static.licenseActivation.enterKey',
'Enter your license key below to activate your plan:'
)}
</Text>
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'plan.static.licenseActivation.keyDescription',
'Paste the license key from your email'
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t('plan.static.licenseActivation.activate', 'Activate License')}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t('common.back', 'Back')}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;
@@ -1,20 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import React, { useState } from 'react';
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { alert } from '@app/components/toast';
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
@@ -22,38 +18,45 @@ interface StaticPlanSectionProps {
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [showComparison, setShowComparison] = useState(false);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server');
const [isUpgrade, setIsUpgrade] = useState(false);
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === 'free' && plan === 'enterprise') {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
),
});
return;
}
setSelectedPlan(plan);
setIsUpgrade(upgrade);
setCheckoutModalOpened(true);
};
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: 'warning',
title: t('plan.static.billingPortal.title', 'Email Verification Required'),
body: t(
'plan.static.billingPortal.message',
'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.'
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank');
};
const staticPlans = [
@@ -122,7 +125,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
paddingBottom: '0.1rem',
}}
>
{staticPlans.map((plan) => (
@@ -131,53 +134,27 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
}}
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
@@ -195,18 +172,123 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
);
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
// Server Plan
if (plan.id === 'server') {
if (currentTier === 'free') {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.free.included', 'Included')}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip label={t('plan.enterprise.requiresServer', 'Requires Server plan')} position="top" withArrow>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.enterprise.requiresServer', 'Requires Server')}
</Button>
</Tooltip>
);
}
if (currentTier === 'server') {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
@@ -230,66 +312,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);
@@ -0,0 +1,56 @@
/**
* Static Stripe payment links for offline/self-hosted environments
*
* These links are used when Supabase is not configured, allowing users to
* purchase licenses directly through Stripe hosted checkout pages.
*
* NOTE: These are test environment URLs. Replace with production URLs before release.
*/
export interface StaticStripeLinks {
server: {
monthly: string;
yearly: string;
};
enterprise: {
monthly: string;
yearly: string;
};
billingPortal: string;
}
// PRODCUTION LINKS FOR LIVE SERVER
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
server: {
monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03',
yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02',
},
enterprise: {
monthly: '',
yearly: '',
},
billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
};
// LINKS FOR TEST SERVER:
// export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
// server: {
// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02',
// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03',
// },
// enterprise: {
// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04',
// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05',
// },
// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
// };
/**
* Builds a Stripe URL with a prefilled email parameter
* @param baseUrl - The base Stripe checkout URL
* @param email - The email address to prefill
* @returns The complete URL with encoded email parameter
*/
export function buildStripeUrlWithEmail(baseUrl: string, email: string): string {
const encodedEmail = encodeURIComponent(email);
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
}
@@ -0,0 +1,40 @@
/**
* Shared utilities for plan tier comparisons and button logic
*/
export type PlanTier = 'free' | 'server' | 'enterprise';
const TIER_HIERARCHY: Record<PlanTier, number> = {
'free': 1,
'server': 2,
'enterprise': 3,
};
/**
* Get numeric level for a tier
*/
export function getTierLevel(tier: PlanTier | string | null | undefined): number {
if (!tier) return 1;
return TIER_HIERARCHY[tier as PlanTier] || 1;
}
/**
* Check if target tier is the current tier
*/
export function isCurrentTier(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) === getTierLevel(targetTier);
}
/**
* Check if target tier is a downgrade from current tier
*/
export function isDowngrade(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) > getTierLevel(targetTier);
}
/**
* Check if enterprise is blocked for free tier users
*/
export function isEnterpriseBlockedForFree(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return currentTier === 'free' && targetTier === 'enterprise';
}
+10
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig(({ mode }) => {
// When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features
@@ -20,6 +21,15 @@ export default defineConfig(({ mode }) => {
tsconfigPaths({
projects: [tsconfigProject],
}),
viteStaticCopy({
targets: [
{
//provides static pdfium so embedpdf can run without cdn
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
dest: 'pdfium'
}
]
})
],
server: {
host: true,