mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Feature/onboarding slides (#4955)
# Description of Changes - Added onboarding slides/upgrade banner conditions for all the following cases - 'licensed' - 'no-login-user-under-limit-no-license' - 'no-login-admin-under-limit-no-license' - 'no-login-user-over-limit-no-license' - 'no-login-admin-over-limit-no-license' - 'login-user-under-limit-no-license' - 'login-admin-under-limit-no-license' - 'login-user-over-limit-no-license' - 'login-admin-over-limit-no-license'; --- ## 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. --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
Connor Yoh
parent
80f2980755
commit
a8db2fda18
Generated
+610
-600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<svg width="110" height="110" viewBox="0 0 110 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_3814_84404)">
|
||||
<rect x="3" y="3" width="100" height="100" rx="22.6562" fill="#8E3131"/>
|
||||
</g>
|
||||
<path d="M18.3374 52.9353L63.6932 15.5L63.6932 50.4604L18.3374 87.8957L18.3374 52.9353Z" fill="white"/>
|
||||
<path d="M42.247 55.4981L87.6566 18.0184L87.6566 53.0203L42.247 90.5L42.247 55.4981Z" fill="white" fill-opacity="0.6"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_3814_84404" x="4.76837e-07" y="4.76837e-07" width="110" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="2" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="2.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3814_84404"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3814_84404" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg width="99" height="108" viewBox="0 0 99 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_3814_84410)">
|
||||
<path d="M70.0628 0.142909L13.7675 11.4215C8.05881 12.5613 4 17.1507 4 22.46V87.4616C4 92.081 7.79482 95.9505 12.8106 96.4905L42.3442 100L68.3543 76.4604L94.7457 51.8862L94.2837 14.691C94.2177 10.1616 89.5979 6.80205 84.7471 7.76192L78.2134 9.05176L78.2464 6.23212C78.3124 2.27263 74.2866 -0.696983 70.0628 0.142909Z" fill="#C02223"/>
|
||||
<path d="M78.2812 9.05175V68.0241L94.8135 51.8562L94.3515 13.4312C94.3185 9.53168 90.3257 6.65206 86.1349 7.49195L78.2812 9.05175Z" fill="#8B2229"/>
|
||||
<path d="M42.3516 99.9997L86.5559 89.5244L94.8153 51.8553L42.3516 99.9997Z" fill="url(#paint0_linear_3814_84410)" fill-opacity="0.5"/>
|
||||
<path d="M55.5058 41.5075C55.5058 41.5075 50.4571 35.5383 46.7282 33.9185C44.3523 32.8986 40.8215 32.5087 38.0496 33.7685C33.9578 35.6283 33.5289 40.8476 37.2907 42.9173C38.1816 43.4272 39.3696 43.8472 40.9205 44.1771C49.2361 45.9169 62.9635 49.5165 61.2805 64.0046C61.2805 64.0046 60.3236 77.5928 44.5833 79.1526C41.8115 79.4226 39.0066 79.2426 36.3007 78.6727C32.5719 77.9228 25.9722 76.393 22.7384 74.5632L22.1444 59.0552H23.7283C23.7283 59.0552 26.8632 65.9543 35.7728 69.2839C37.4227 69.8838 39.2376 70.1238 40.9865 69.8838C43.1974 69.5839 45.8043 68.504 46.8602 65.2344C46.8602 65.2344 48.6421 60.465 39.4026 57.8554C32.0109 55.7557 25.0483 53.5959 22.1774 46.5168C20.8245 43.1573 20.5935 39.4378 21.5174 35.8682C22.7384 31.2188 26.2362 24.6797 36.1357 21.86C36.1357 21.86 49.1371 18.6504 58.0467 22.9999L57.6507 41.5075H55.5058Z" fill="url(#paint1_linear_3814_84410)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_3814_84410" x="0" y="0" width="98.8125" height="108" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3814_84410"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3814_84410" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_3814_84410" x1="65.4105" y1="100.093" x2="68.5651" y2="71.0126" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DCF1F3"/>
|
||||
<stop offset="1" stop-color="#C2C2C9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3814_84410" x1="38.5393" y1="85.0187" x2="43.5426" y2="7.50144" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DCF1F3"/>
|
||||
<stop offset="1" stop-color="#C2C2C9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="57" height="52" viewBox="0 0 57 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.07814 26.5002L34.9865 -1.74377e-05L34.9865 23.5005L4.07809 50.0008L4.07814 26.5002Z" fill="#ACACAC" fill-opacity="0.8"/>
|
||||
<path d="M20.8792 27.6687L52.0781 1.50155L52.0781 25.8343L20.8793 52.0015L20.8792 27.6687Z" fill="#FC9999" fill-opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -294,7 +294,8 @@
|
||||
"learnMore": "Learn more",
|
||||
"enable": "Enable analytics",
|
||||
"disable": "Disable analytics",
|
||||
"settings": "You can change the settings for analytics in the config/settings.yml file"
|
||||
"settings": "You can change the settings for analytics in the config/settings.yml file",
|
||||
"privacyAssurance": "We do not track any personal information or the contents of your files."
|
||||
},
|
||||
"navbar": {
|
||||
"favorite": "Favorites",
|
||||
@@ -5123,6 +5124,10 @@
|
||||
"maybeLater": "Maybe Later",
|
||||
"dontShowAgain": "Don't Show Again"
|
||||
},
|
||||
"welcomeSlide": {
|
||||
"title": "Welcome to Stirling",
|
||||
"body": "Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
|
||||
},
|
||||
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||
@@ -5145,7 +5150,39 @@
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"startTour": "Start Tour",
|
||||
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
|
||||
"startTourDescription": "Take a guided tour of Stirling PDF's key features",
|
||||
"buttons": {
|
||||
"next": "Next →",
|
||||
"back": "Back",
|
||||
"skipForNow": "Skip for now",
|
||||
"download": "Download →",
|
||||
"showMeAround": "Show me around",
|
||||
"skipTheTour": "Skip the tour"
|
||||
},
|
||||
"serverLicense": {
|
||||
"skip": "Skip for now",
|
||||
"seePlans": "See Plans →",
|
||||
"upgrade": "Upgrade now →",
|
||||
"freeTitle": "Server License",
|
||||
"overLimitTitle": "Server License Needed",
|
||||
"overLimitBody": "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo.",
|
||||
"freeBody": "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
|
||||
},
|
||||
"desktopInstall": {
|
||||
"title": "Download",
|
||||
"titleWithOs": "Download for {{osLabel}}",
|
||||
"body": "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
|
||||
},
|
||||
"planOverview": {
|
||||
"adminTitle": "Admin Overview",
|
||||
"userTitle": "Plan Overview",
|
||||
"adminBodyLoginEnabled": "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.",
|
||||
"adminBodyLoginDisabled": "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.",
|
||||
"userBody": "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use."
|
||||
},
|
||||
"securityCheck": {
|
||||
"message": "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
||||
}
|
||||
},
|
||||
"adminOnboarding": {
|
||||
"welcome": "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
|
||||
@@ -5174,7 +5211,10 @@
|
||||
"role": "Role",
|
||||
"team": "Team",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"actions": {
|
||||
"label": "Actions",
|
||||
"upgrade": "Upgrade"
|
||||
},
|
||||
"noMembersFound": "No members found",
|
||||
"active": "Active",
|
||||
"disabled": "Disabled",
|
||||
@@ -5286,7 +5326,8 @@
|
||||
"slotsAvailable": "{{count}} user slot(s) available",
|
||||
"noSlotsAvailable": "No slots available",
|
||||
"currentUsage": "Currently using {{current}} of {{max}} user licences"
|
||||
}
|
||||
},
|
||||
"loginRequired": "Enable login mode first"
|
||||
},
|
||||
"teams": {
|
||||
"title": "Teams",
|
||||
@@ -5440,6 +5481,12 @@
|
||||
"api": "API Access",
|
||||
"priority": "Priority Support",
|
||||
"customPricing": "Custom Pricing"
|
||||
},
|
||||
"licenseWarning": {
|
||||
"title": "Free self-hosted limit reached",
|
||||
"body": "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly.",
|
||||
"overLimit": "more than {{limit}}",
|
||||
"cta": "See plans"
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
@@ -5481,7 +5528,11 @@
|
||||
"title": "Upgrade to Server Plan",
|
||||
"message": "Get the most out of Stirling PDF with unlimited users and advanced features",
|
||||
"upgradeButton": "Upgrade Now",
|
||||
"dismiss": "Dismiss banner"
|
||||
"dismiss": "Dismiss banner",
|
||||
"attentionTitle": "This server needs admin attention",
|
||||
"attentionBody": "Your admin needs to sign in to see more info. Please contact them immediately.",
|
||||
"attentionBodyAdmin": "Review the license requirements to keep this server compliant.",
|
||||
"seeInfo": "See info"
|
||||
},
|
||||
"payment": {
|
||||
"preparing": "Preparing your checkout...",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import { BannerProvider } from "@app/contexts/BannerContext";
|
||||
import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@@ -57,35 +58,37 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
<CookieConsentProvider>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</CookieConsentProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
.heroWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroLogo {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.heroLogoCircle {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.heroLogoCircle img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
animation: heroLogoRotate 0.25s ease forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.standaloneIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.securitySlideContent {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.securityCard {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.securityAlertRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--onboarding-body, #1f2937);
|
||||
}
|
||||
|
||||
.heroIconsContainer {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
position: relative;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.iconButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.iconButtonSelected {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.iconLabel {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes heroLogoEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoScale {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoRotate {
|
||||
from {
|
||||
transform: rotate(-90deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bodySlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev overlay styles */
|
||||
.devOverlay {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.devButton {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.devButtonActive {
|
||||
opacity: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1F2933;
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modalContent {
|
||||
background: var(--bg-surface);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Title styles */
|
||||
.titleText {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--onboarding-title);
|
||||
}
|
||||
|
||||
/* Body text styles */
|
||||
.bodyText {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--onboarding-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bodyCopyInner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Button margin */
|
||||
.buttonContainer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Welcome slide V2 badge */
|
||||
.welcomeTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.v2Badge {
|
||||
background: #DBEFFF;
|
||||
color: #2A4BFF;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.heroIcon {
|
||||
color: #000000;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
|
||||
|
||||
export interface FlowConfig {
|
||||
type: FlowType;
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
|
||||
if (!enableLogin) {
|
||||
return selfReportedAdmin
|
||||
? {
|
||||
type: 'no-login-admin',
|
||||
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
|
||||
}
|
||||
: {
|
||||
type: 'no-login',
|
||||
ids: FLOW_SEQUENCES.noLoginBase,
|
||||
};
|
||||
}
|
||||
|
||||
return isAdmin
|
||||
? {
|
||||
type: 'login-admin',
|
||||
ids: FLOW_SEQUENCES.loginAdmin,
|
||||
}
|
||||
: {
|
||||
type: 'login-user',
|
||||
ids: FLOW_SEQUENCES.loginUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
||||
const flow = useInitialOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/branding/StirlingLogoLegacy.svg`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === 'rocket' && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'shield' && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
closeOnClickOutside={false}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Button, Group, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
};
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition['variant']) =>
|
||||
variant === 'primary'
|
||||
? {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Special case: override "See Plans" with "Upgrade now" when over limit
|
||||
if (
|
||||
button.type === 'button' &&
|
||||
slideDefinition.id === 'server-license' &&
|
||||
button.action === 'see-plans' &&
|
||||
licenseNotice.isOverLimit
|
||||
) {
|
||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||
}
|
||||
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === 'icon') {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? 'secondary';
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (leftButtons.length === 0) {
|
||||
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
if (rightButtons.length === 0) {
|
||||
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
step: number;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: OnboardingState = {
|
||||
step: 0,
|
||||
selectedRole: null,
|
||||
selfReportedAdmin: false,
|
||||
};
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface UseInitialOnboardingStateResult {
|
||||
state: OnboardingState;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
closeAndMarkSeen: () => void;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function useInitialOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
onRequestServerLicense,
|
||||
onLicenseNoticeUpdate,
|
||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour } = useOnboarding();
|
||||
const {
|
||||
loginEnabled: loginEnabledFromServer,
|
||||
configIsAdmin,
|
||||
totalUsers: serverTotalUsers,
|
||||
userCountResolved: serverUserCountResolved,
|
||||
freeTierLimit,
|
||||
hasPaidLicense,
|
||||
scenarioKey,
|
||||
setSelfReportedAdmin,
|
||||
isNewServer,
|
||||
} = useServerExperience();
|
||||
const osType = useOs();
|
||||
const navigate = useNavigate();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
|
||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
resetState();
|
||||
}
|
||||
}, [opened, resetState]);
|
||||
|
||||
const handleRoleSelect = useCallback(
|
||||
(role: 'admin' | 'user' | null) => {
|
||||
const isAdminSelection = role === 'admin';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedRole: role,
|
||||
selfReportedAdmin: isAdminSelection,
|
||||
}));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isAdminSelection) {
|
||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
||||
}
|
||||
}
|
||||
|
||||
setSelfReportedAdmin(isAdminSelection);
|
||||
},
|
||||
[setSelfReportedAdmin],
|
||||
);
|
||||
|
||||
const closeAndMarkSeen = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
const enableLogin = loginEnabledFromServer;
|
||||
|
||||
const effectiveEnableLogin = enableLogin;
|
||||
const effectiveIsAdmin = isAdmin;
|
||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
||||
handleRoleSelect('admin');
|
||||
}
|
||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
||||
|
||||
const shouldUseServerCount =
|
||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
||||
const licenseUserCountFromServer =
|
||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
||||
|
||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
||||
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
}, []);
|
||||
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
||||
);
|
||||
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
|
||||
const flowSlideIds = useMemo(
|
||||
() =>
|
||||
shouldSkipSecurityCheck
|
||||
? resolvedFlow.ids.filter((id) => id !== 'security-check')
|
||||
: resolvedFlow.ids,
|
||||
[resolvedFlow.ids, shouldSkipSecurityCheck],
|
||||
);
|
||||
const flowType = resolvedFlow.type;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.step >= flowSlideIds.length) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(flowSlideIds.length - 1, 0),
|
||||
}));
|
||||
}
|
||||
}, [flowSlideIds.length, state.step]);
|
||||
|
||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
if (!slideDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
||||
const scenarioRequiresLicense =
|
||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
||||
|
||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
||||
|
||||
const licenseNotice = useMemo<LicenseNotice>(
|
||||
() => ({
|
||||
totalUsers: effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
isOverLimit: scenarioIndicatesOverLimit,
|
||||
requiresLicense: shouldShowServerLicenseInfo,
|
||||
}),
|
||||
[
|
||||
effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
scenarioIndicatesOverLimit,
|
||||
shouldShowServerLicenseInfo,
|
||||
],
|
||||
);
|
||||
|
||||
const requestServerLicenseIfNeeded = useCallback(
|
||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
||||
if (!shouldShowServerLicenseInfo) {
|
||||
return;
|
||||
}
|
||||
onRequestServerLicense?.(options);
|
||||
},
|
||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLicenseNoticeUpdate?.(licenseNotice);
|
||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
||||
|
||||
// Initialize ref with default URL
|
||||
useEffect(() => {
|
||||
if (!selectedDownloadUrlRef.current && os.url) {
|
||||
selectedDownloadUrlRef.current = os.url;
|
||||
}
|
||||
}, [os.url]);
|
||||
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
const currentSlide = slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
selectedRole: state.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice,
|
||||
loginEnabled: effectiveEnableLogin,
|
||||
});
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.min(prev.step + 1, maxIndex),
|
||||
}));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(prev.step - 1, 0),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const launchTour = useCallback(
|
||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
||||
if (options?.closeOnboardingSlides) {
|
||||
closeAndMarkSeen();
|
||||
}
|
||||
|
||||
startTour(mode, {
|
||||
source: 'initial-onboarding-modal',
|
||||
metadata: {
|
||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
||||
selfReportedAdmin: state.selfReportedAdmin,
|
||||
},
|
||||
});
|
||||
},
|
||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
const currentSlideIdLocal = currentSlideId;
|
||||
const shouldAutoLaunchLoginUserTour =
|
||||
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
|
||||
|
||||
switch (action) {
|
||||
case 'next':
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
case 'prev':
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
case 'complete-close':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'security-next':
|
||||
if (!state.selectedRole) {
|
||||
return;
|
||||
}
|
||||
if (state.selectedRole === 'admin') {
|
||||
goNext();
|
||||
} else {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
}
|
||||
return;
|
||||
case 'launch-admin':
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
launchTour('admin', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-tools':
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-auto': {
|
||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
||||
if (launchMode === 'admin') {
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
}
|
||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: false,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'see-plans':
|
||||
closeAndMarkSeen();
|
||||
navigate('/settings/adminPlan');
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
closeAndMarkSeen,
|
||||
currentSlide,
|
||||
effectiveIsAdmin,
|
||||
flowType,
|
||||
goNext,
|
||||
goPrev,
|
||||
launchTour,
|
||||
navigate,
|
||||
requestServerLicenseIfNeeded,
|
||||
onRequestServerLicense,
|
||||
os.url,
|
||||
state.selectedRole,
|
||||
state.selfReportedAdmin,
|
||||
updatePreference,
|
||||
],
|
||||
);
|
||||
|
||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
||||
|
||||
return {
|
||||
state,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
totalSteps: number;
|
||||
activeStep: number; // 0-indexed
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a progress indicator where the active step is a pill and others are dots.
|
||||
* Colors come from theme.css variables.
|
||||
*/
|
||||
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
|
||||
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((index) => {
|
||||
const isActive = index === activeStep;
|
||||
const baseStyles: React.CSSProperties = {
|
||||
background: isActive
|
||||
? 'var(--onboarding-step-active)'
|
||||
: 'var(--onboarding-step-inactive)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
...baseStyles,
|
||||
width: isActive ? 44 : 8,
|
||||
height: 8,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingStepper;
|
||||
|
||||
|
||||
@@ -1,105 +1,29 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { TourProvider, type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
||||
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import TourContent from '@app/components/onboarding/TourContent';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
// Sync tour open state with context and reset to step 0 when reopening
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
// Tour is being opened (Help button pressed), reset to first step
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
|
||||
const flow = useOnboardingFlow();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
|
||||
// Helper to add glow to multiple elements
|
||||
const addGlowToElements = (selectors: string[]) => {
|
||||
selectors.forEach(selector => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
if (selector === '[data-tour="settings-content-area"]') {
|
||||
element.classList.add('tour-content-glow');
|
||||
} else {
|
||||
element.classList.add('tour-nav-glow');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to remove all glows
|
||||
const removeAllGlows = () => {
|
||||
document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
|
||||
document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
|
||||
};
|
||||
|
||||
// Cleanup glows when tour closes
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
removeAllGlows();
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [isOpen]);
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
@@ -122,258 +46,78 @@ export default function OnboardingTour() {
|
||||
scrollNavToSection,
|
||||
} = useAdminTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
}
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
}), [t]);
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
|
||||
// Define admin tour steps
|
||||
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
|
||||
[AdminTourStep.WELCOME]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveAdminState();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONFIG_BUTTON]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
actionAfter: () => {
|
||||
openConfigModal();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
||||
selector: '.modal-nav',
|
||||
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.TEAMS_AND_USERS]: {
|
||||
selector: '[data-tour="admin-people-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('people');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
||||
selector: '[data-tour="admin-adminGeneral-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminGeneral');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminDatabase');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminConnections');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
// Scroll for the NEXT step before it shows
|
||||
await scrollNavToSection('adminAudit');
|
||||
},
|
||||
},
|
||||
[AdminTourStep.ADMIN_TOOLS]: {
|
||||
selector: '[data-tour="admin-adminAudit-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
// Just navigate, scroll already happened in previous step
|
||||
removeAllGlows();
|
||||
navigateToSection('adminAudit');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
}), [t]);
|
||||
useEffect(() => {
|
||||
if (!flow.isTourOpen) {
|
||||
removeAllGlows();
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [flow.isTourOpen]);
|
||||
|
||||
// Select steps based on tour type
|
||||
const steps = tourType === 'admin'
|
||||
? Object.values(adminStepsConfig)
|
||||
: Object.values(stepsConfig);
|
||||
const userStepsConfig = useMemo(
|
||||
() =>
|
||||
createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
backToAllTools,
|
||||
closeFilesModal,
|
||||
executeTool,
|
||||
loadSampleFile,
|
||||
modifyCropSettings,
|
||||
openFilesModal,
|
||||
pinFile,
|
||||
saveWorkbenchState,
|
||||
selectCropTool,
|
||||
selectFirstFile,
|
||||
switchToActiveFiles,
|
||||
switchToPageEditor,
|
||||
switchToViewer,
|
||||
],
|
||||
);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
const adminStepsConfig = useMemo(
|
||||
() =>
|
||||
createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
||||
);
|
||||
|
||||
const steps = useMemo<StepType[]>(() => {
|
||||
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
|
||||
|
||||
const advanceTour = ({
|
||||
setCurrentStep,
|
||||
currentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
}: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
@@ -381,12 +125,12 @@ export default function OnboardingTour() {
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
if (tourType === 'admin') {
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
completeTour();
|
||||
flow.handleTourCompletion();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
@@ -394,34 +138,22 @@ export default function OnboardingTour() {
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
if (tourType === 'admin') {
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
completeTour();
|
||||
flow.handleTourCompletion();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
||||
<TourProvider
|
||||
key={`${tourType}-${i18n.language}`}
|
||||
key={`${flow.tourType}-${i18n.language}`}
|
||||
steps={steps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
maskClassName={flow.maskClassName}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
@@ -429,13 +161,10 @@ export default function OnboardingTour() {
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
// Handle right arrow key to advance tour
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
}
|
||||
// Handle escape key to close tour
|
||||
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
@@ -487,22 +216,16 @@ export default function OnboardingTour() {
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: 'absolute', top: '8px', right: '8px' }}
|
||||
/>
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content } : {content: string}) => (
|
||||
<div
|
||||
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
<ServerLicenseModal {...flow.serverLicenseModalProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { LicenseNotice } from '@app/types/types';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface ServerLicenseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSeePlans?: () => void;
|
||||
licenseNotice: LicenseNotice;
|
||||
}
|
||||
|
||||
export default function ServerLicenseModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSeePlans,
|
||||
licenseNotice,
|
||||
}: ServerLicenseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
|
||||
const primaryLabel = licenseNotice.isOverLimit
|
||||
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
|
||||
: t('onboarding.serverLicense.seePlans', 'See Plans →');
|
||||
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
|
||||
|
||||
const handleSeePlans = () => {
|
||||
onSeePlans?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const secondaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const primaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={slide.background.gradientStops}
|
||||
circles={slide.background.circles}
|
||||
isActive
|
||||
slideKey={slide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 24 }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
className={styles.title}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: 22,
|
||||
color: 'var(--onboarding-title)',
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
</div>
|
||||
<div
|
||||
className={styles.bodyCopy}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontSize: 16,
|
||||
color: 'var(--onboarding-body)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{slide.body}
|
||||
</div>
|
||||
<Group justify="space-between">
|
||||
<Button styles={secondaryStyles} onClick={onClose}>
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
<Button styles={primaryStyles} onClick={handleSeePlans}>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useTour } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
export default function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
|
||||
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
|
||||
interface AdminStepActions {
|
||||
saveAdminState: () => void;
|
||||
openConfigModal: () => void;
|
||||
navigateToSection: (section: string) => void;
|
||||
scrollNavToSection: (section: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface CreateAdminStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: AdminStepActions;
|
||||
}
|
||||
|
||||
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
|
||||
|
||||
return {
|
||||
[AdminTourStep.WELCOME]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveAdminState();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONFIG_BUTTON]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
actionAfter: () => {
|
||||
openConfigModal();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
||||
selector: '.modal-nav',
|
||||
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.TEAMS_AND_USERS]: {
|
||||
selector: '[data-tour="admin-people-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('people');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
||||
selector: '[data-tour="admin-adminGeneral-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminGeneral');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminDatabase');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminConnections');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
await scrollNavToSection('adminAudit');
|
||||
},
|
||||
},
|
||||
[AdminTourStep.ADMIN_TOOLS]: {
|
||||
selector: '[data-tour="admin-adminAudit-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminAudit');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
} from '@app/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
interface InitialModalHandlers {
|
||||
opened: boolean;
|
||||
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
|
||||
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ServerLicenseModalHandlers {
|
||||
opened: boolean;
|
||||
licenseNotice: LicenseNotice;
|
||||
onClose: () => void;
|
||||
onSeePlans: () => void;
|
||||
}
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding;
|
||||
const isAdminUser = !!config?.isAdmin;
|
||||
const { hasPaidLicense } = useServerExperience();
|
||||
|
||||
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
});
|
||||
const [cookieBannerPending, setCookieBannerPending] = useState(false);
|
||||
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
|
||||
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
|
||||
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
|
||||
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
|
||||
const [toolPromptCompleted, setToolPromptCompleted] = useState(
|
||||
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
|
||||
);
|
||||
const introWasOpenRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const onboardingSessionMarkedRef = useRef(false);
|
||||
|
||||
const handleInitialModalClose = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
|
||||
setLicenseNotice(notice);
|
||||
}, []);
|
||||
|
||||
const handleToolPromptComplete = useCallback(() => {
|
||||
setToolPromptCompleted(true);
|
||||
}, []);
|
||||
|
||||
const maybeShowCookieBanner = useCallback(() => {
|
||||
if (preferences.hasSeenCookieBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
|
||||
setCookieBannerPending(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCookieBannerPending(false);
|
||||
showCookieConsent();
|
||||
updatePreference('hasSeenCookieBanner', true);
|
||||
}, [
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
preferences.hasSeenCookieBanner,
|
||||
serverLicenseIntent,
|
||||
showCookieConsent,
|
||||
toolPromptCompleted,
|
||||
updatePreference,
|
||||
]);
|
||||
|
||||
const requestServerLicense = useCallback(
|
||||
({
|
||||
deferUntilTourComplete = false,
|
||||
selfReportedAdmin = false,
|
||||
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
|
||||
const qualifies = isAdminUser || selfReportedAdmin;
|
||||
if (!qualifies) {
|
||||
return;
|
||||
}
|
||||
if (hasPaidLicense || !licenseNotice.requiresLicense) {
|
||||
return;
|
||||
}
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
setServerLicenseIntent((prev) => {
|
||||
if (prev === 'pending') {
|
||||
return prev;
|
||||
}
|
||||
if (prev === 'deferred') {
|
||||
return deferUntilTourComplete ? prev : 'pending';
|
||||
}
|
||||
if (prev === 'idle') {
|
||||
return deferUntilTourComplete ? 'deferred' : 'pending';
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleServerLicenseRequested = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||
|
||||
if (detail?.licenseNotice) {
|
||||
setLicenseNotice((prev) => ({
|
||||
...prev,
|
||||
...detail.licenseNotice,
|
||||
totalUsers:
|
||||
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
|
||||
freeTierLimit:
|
||||
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
|
||||
isOverLimit:
|
||||
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
|
||||
requiresLicense:
|
||||
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
|
||||
}));
|
||||
}
|
||||
|
||||
requestServerLicense({
|
||||
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
|
||||
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
};
|
||||
}, [requestServerLicense]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cookieBannerPending &&
|
||||
isCookieConsentReady &&
|
||||
serverLicenseIntent === 'idle' &&
|
||||
!isServerLicenseOpen &&
|
||||
toolPromptCompleted
|
||||
) {
|
||||
maybeShowCookieBanner();
|
||||
}
|
||||
}, [
|
||||
cookieBannerPending,
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
toolPromptCompleted,
|
||||
maybeShowCookieBanner,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
introWasOpenRef.current &&
|
||||
!shouldShowIntro &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
!hasShownServerLicense &&
|
||||
licenseNotice.requiresLicense &&
|
||||
serverLicenseIntent === 'idle'
|
||||
) {
|
||||
if (!serverLicenseSource) {
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
}
|
||||
setServerLicenseIntent('pending');
|
||||
}
|
||||
introWasOpenRef.current = shouldShowIntro;
|
||||
}, [
|
||||
hasShownServerLicense,
|
||||
isAdminUser,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
serverLicenseIntent !== 'idle' &&
|
||||
!shouldShowIntro &&
|
||||
!isOpen &&
|
||||
!isServerLicenseOpen &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
licenseNotice.requiresLicense
|
||||
) {
|
||||
setIsServerLicenseOpen(true);
|
||||
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
|
||||
}
|
||||
}, [
|
||||
isAdminUser,
|
||||
isOpen,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
const handleServerLicenseClose = useCallback(() => {
|
||||
setIsServerLicenseOpen(false);
|
||||
setHasShownServerLicense(true);
|
||||
setServerLicenseIntent('idle');
|
||||
setServerLicenseSource(null);
|
||||
maybeShowCookieBanner();
|
||||
}, [maybeShowCookieBanner]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingSessionMarkedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (shouldShowIntro || isOpen) {
|
||||
onboardingSessionMarkedRef.current = true;
|
||||
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowIntro && !isOpen) {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
const handleServerLicenseSeePlans = useCallback(() => {
|
||||
handleServerLicenseClose();
|
||||
navigate('/settings/adminPlan');
|
||||
}, [handleServerLicenseClose, navigate]);
|
||||
|
||||
const handleTourCompletion = useCallback(() => {
|
||||
completeTour();
|
||||
if (serverLicenseIntent === 'deferred') {
|
||||
setServerLicenseIntent('pending');
|
||||
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
|
||||
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
|
||||
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
|
||||
}
|
||||
maybeShowCookieBanner();
|
||||
}, [
|
||||
completeTour,
|
||||
isAdminUser,
|
||||
maybeShowCookieBanner,
|
||||
serverLicenseIntent,
|
||||
serverLicenseSource,
|
||||
tourType,
|
||||
]);
|
||||
|
||||
const initialModalProps: InitialModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: shouldShowIntro,
|
||||
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
|
||||
onRequestServerLicense: requestServerLicense,
|
||||
onClose: handleInitialModalClose,
|
||||
}),
|
||||
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
|
||||
);
|
||||
|
||||
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: isServerLicenseOpen,
|
||||
licenseNotice,
|
||||
onClose: handleServerLicenseClose,
|
||||
onSeePlans: handleServerLicenseSeePlans,
|
||||
}),
|
||||
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
|
||||
);
|
||||
|
||||
return {
|
||||
tourType,
|
||||
isTourOpen: isOpen,
|
||||
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
|
||||
initialModalProps,
|
||||
handleToolPromptComplete,
|
||||
serverLicenseModalProps,
|
||||
handleTourCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
||||
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
| 'welcome'
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'close'
|
||||
| 'complete-close'
|
||||
| 'download-selected'
|
||||
| 'security-next'
|
||||
| 'launch-admin'
|
||||
| 'launch-tools'
|
||||
| 'launch-auto'
|
||||
| 'see-plans'
|
||||
| 'skip-to-license';
|
||||
|
||||
export interface FlowState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
}
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SlideFactoryParams {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
type: HeroType;
|
||||
}
|
||||
|
||||
export interface ButtonDefinition {
|
||||
key: string;
|
||||
type: 'button' | 'icon';
|
||||
label?: string;
|
||||
icon?: 'chevron-left';
|
||||
variant?: 'primary' | 'secondary' | 'default';
|
||||
group: 'left' | 'right';
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
}
|
||||
|
||||
export interface SlideDefinition {
|
||||
id: SlideId;
|
||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
||||
hero: HeroDefinition;
|
||||
buttons: ButtonDefinition[];
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'welcome': {
|
||||
id: 'welcome',
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'welcome-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
},
|
||||
],
|
||||
},
|
||||
'desktop-install': {
|
||||
id: 'desktop-install',
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'desktop-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'desktop-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'next',
|
||||
},
|
||||
{
|
||||
key: 'desktop-download',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.download',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'download-selected',
|
||||
},
|
||||
],
|
||||
},
|
||||
'security-check': {
|
||||
id: 'security-check',
|
||||
createSlide: ({ selectedRole, onRoleSelect }) =>
|
||||
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
hero: { type: 'shield' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'security-back',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.back',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'security-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'security-next',
|
||||
disabledWhen: (state) => !state.selectedRole,
|
||||
},
|
||||
],
|
||||
},
|
||||
'admin-overview': {
|
||||
id: 'admin-overview',
|
||||
createSlide: ({ licenseNotice, loginEnabled }) =>
|
||||
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
hero: { type: 'diamond' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'admin-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'admin-show',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.showMeAround',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-admin',
|
||||
},
|
||||
{
|
||||
key: 'admin-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipTheTour',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'skip-to-license',
|
||||
},
|
||||
],
|
||||
},
|
||||
'server-license': {
|
||||
id: 'server-license',
|
||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||
hero: { type: 'logo' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'license-close',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'close',
|
||||
},
|
||||
{
|
||||
key: 'license-see-plans',
|
||||
type: 'button',
|
||||
label: 'onboarding.serverLicense.seePlans',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'see-plans',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
|
||||
loginUser: ['welcome', 'desktop-install'] as SlideId[],
|
||||
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
|
||||
noLoginAdmin: ['admin-overview'] as SlideId[],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.gradientLayer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 180% 180%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gradientLayerActive {
|
||||
opacity: 1;
|
||||
animation: gradientShift 18s ease-in-out infinite alternate;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gradientLayerPrev {
|
||||
opacity: 1;
|
||||
z-index: 3;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.gradientLayerPrevFadeOut {
|
||||
opacity: 0;
|
||||
z-index: 3;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
|
||||
animation-name: circleSway;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-duration: var(--circle-duration, 15s);
|
||||
animation-delay: var(--circle-delay, 0s);
|
||||
will-change: transform;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circleSway {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
|
||||
import { AnimatedSlideBackgroundProps } from '@app/types/types';
|
||||
|
||||
type CircleStyles = React.CSSProperties & {
|
||||
'--circle-move-x'?: string;
|
||||
'--circle-move-y'?: string;
|
||||
'--circle-duration'?: string;
|
||||
'--circle-delay'?: string;
|
||||
};
|
||||
|
||||
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
|
||||
isActive: boolean;
|
||||
slideKey: string;
|
||||
}
|
||||
|
||||
export default function AnimatedSlideBackground({
|
||||
gradientStops,
|
||||
circles,
|
||||
isActive,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||
const isFirstMount = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Skip transition on first mount
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false;
|
||||
setCurrentGradient(gradientStops);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only transition if gradient actually changed
|
||||
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
|
||||
// Store previous gradient and start transition
|
||||
setPrevGradient(currentGradient);
|
||||
setIsTransitioning(true);
|
||||
|
||||
// Update to new gradient (will fade in)
|
||||
setCurrentGradient(gradientStops);
|
||||
}
|
||||
}, [gradientStops]);
|
||||
|
||||
const currentGradientStyle = React.useMemo(
|
||||
() => ({
|
||||
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
|
||||
}),
|
||||
[currentGradient],
|
||||
);
|
||||
|
||||
const prevGradientStyle = prevGradient
|
||||
? {
|
||||
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.hero} key="animated-background">
|
||||
{prevGradientStyle && isTransitioning && (
|
||||
<div
|
||||
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
|
||||
style={prevGradientStyle}
|
||||
onTransitionEnd={() => {
|
||||
setPrevGradient(null);
|
||||
setIsTransitioning(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
|
||||
style={currentGradientStyle}
|
||||
/>
|
||||
{circles.map((circle, index) => {
|
||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
||||
|
||||
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
|
||||
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
|
||||
const circleStyle: CircleStyles = {
|
||||
width: size,
|
||||
height: size,
|
||||
background: color,
|
||||
opacity: opacity ?? 0.9,
|
||||
filter: blur ? `blur(${blur}px)` : undefined,
|
||||
'--circle-move-x': `${moveX}px`,
|
||||
'--circle-move-y': `${moveY}px`,
|
||||
'--circle-duration': `${duration}s`,
|
||||
'--circle-delay': `${delay}s`,
|
||||
};
|
||||
|
||||
const defaultOffset = -size / 2;
|
||||
const offsetX = circle.offsetX ?? 0;
|
||||
const offsetY = circle.offsetY ?? 0;
|
||||
|
||||
if (position === 'bottom-left') {
|
||||
circleStyle.left = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.bottom = `${defaultOffset + offsetY}px`;
|
||||
} else {
|
||||
circleStyle.right = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`circle-${index}-${position}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
|
||||
|
||||
export type { OSOption };
|
||||
|
||||
interface DesktopInstallSlideProps {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
const DesktopInstallBody = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t(
|
||||
'onboarding.desktopInstall.body',
|
||||
'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.',
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DesktopInstallSlide({
|
||||
osLabel,
|
||||
osUrl,
|
||||
osOptions = [],
|
||||
onDownloadUrlChange,
|
||||
}: DesktopInstallSlideProps): SlideConfig {
|
||||
|
||||
return {
|
||||
key: 'desktop-install',
|
||||
title: (
|
||||
<DesktopInstallTitle
|
||||
osLabel={osLabel}
|
||||
osUrl={osUrl}
|
||||
osOptions={osOptions || []}
|
||||
onDownloadUrlChange={onDownloadUrlChange}
|
||||
/>
|
||||
),
|
||||
body: <DesktopInstallBody />,
|
||||
downloadUrl: osUrl,
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#0EA5E9'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, ActionIcon } from '@mantine/core';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DesktopInstallTitleProps {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
osLabel,
|
||||
osUrl,
|
||||
osOptions,
|
||||
onDownloadUrlChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedOsUrl(osUrl);
|
||||
}, [osUrl]);
|
||||
|
||||
const handleOsSelect = React.useCallback((option: OSOption) => {
|
||||
setSelectedOsUrl(option.url);
|
||||
onDownloadUrlChange?.(option.url);
|
||||
}, [onDownloadUrlChange]);
|
||||
|
||||
const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
|
||||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
|
||||
|
||||
const displayLabel = currentOsOption.label || osLabel;
|
||||
const title = displayLabel
|
||||
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
|
||||
: t('onboarding.desktopInstall.title', 'Download');
|
||||
|
||||
// If only one option or no options, don't show dropdown
|
||||
if (osOptions.length <= 1) {
|
||||
return <div style={{ textAlign: 'center', width: '100%' }}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%' }}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
|
||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{osOptions.map((option) => {
|
||||
const isSelected = option.url === selectedOsUrl;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={option.url}
|
||||
onClick={() => handleOsSelect(option)}
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||
: 'transparent',
|
||||
color: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
|
||||
interface PlanOverviewSlideProps {
|
||||
isAdmin: boolean;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{isAdmin
|
||||
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
|
||||
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) => {
|
||||
const adminBodyKey = loginEnabled
|
||||
? 'onboarding.planOverview.adminBodyLoginEnabled'
|
||||
: 'onboarding.planOverview.adminBodyLoginDisabled';
|
||||
|
||||
const defaultValue = loginEnabled
|
||||
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
|
||||
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
|
||||
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={adminBodyKey}
|
||||
values={{ freeTierLimit }}
|
||||
components={{ strong: <strong /> }}
|
||||
defaults={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UserOverviewBody: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span>
|
||||
{t(
|
||||
'onboarding.planOverview.userBody',
|
||||
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
isAdmin,
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) =>
|
||||
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
|
||||
|
||||
export default function PlanOverviewSlide({
|
||||
isAdmin,
|
||||
licenseNotice,
|
||||
loginEnabled = false,
|
||||
}: PlanOverviewSlideProps): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
|
||||
return {
|
||||
key: isAdmin ? 'admin-overview' : 'plan-overview',
|
||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
||||
background: {
|
||||
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Select } from '@mantine/core';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import i18n from '@app/i18n';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface SecurityCheckSlideProps {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
}
|
||||
|
||||
export default function SecurityCheckSlide({
|
||||
selectedRole,
|
||||
onRoleSelect,
|
||||
}: SecurityCheckSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'security-check',
|
||||
title: 'Security Check',
|
||||
body: (
|
||||
<div className={styles.securitySlideContent}>
|
||||
<div className={styles.securityCard}>
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
|
||||
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
placeholder="Confirm your role"
|
||||
value={selectedRole}
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||
styles={{
|
||||
input: {
|
||||
height: 48,
|
||||
fontSize: 15,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#5B21B6', '#2563EB'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
interface ServerLicenseSlideProps {
|
||||
licenseNotice?: LicenseNotice;
|
||||
}
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
const totalUsers = licenseNotice?.totalUsers ?? null;
|
||||
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||
const title = isOverLimit
|
||||
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
|
||||
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
|
||||
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
|
||||
|
||||
const overLimitBody = (
|
||||
<Trans
|
||||
i18nKey="onboarding.serverLicense.overLimitBody"
|
||||
values={{ freeTierLimit, overLimitUserCopy }}
|
||||
components={{
|
||||
strong: <strong />,
|
||||
}}
|
||||
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
|
||||
/>
|
||||
);
|
||||
|
||||
const freeBody = (
|
||||
<Trans
|
||||
i18nKey="onboarding.serverLicense.freeBody"
|
||||
values={{ freeTierLimit }}
|
||||
components={{
|
||||
strong: <strong />,
|
||||
}}
|
||||
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
|
||||
/>
|
||||
);
|
||||
|
||||
const body = isOverLimit ? overLimitBody : freeBody;
|
||||
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
body,
|
||||
background: {
|
||||
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
|
||||
function WelcomeSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={styles.welcomeTitleContainer}>
|
||||
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
|
||||
<span className={styles.v2Badge}>V2</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const WelcomeSlideBody = () => (
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="onboarding.welcomeSlide.body"
|
||||
components={{ strong: <strong /> }}
|
||||
defaults="Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default function WelcomeSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'welcome',
|
||||
title: <WelcomeSlideTitle />,
|
||||
body: <WelcomeSlideBody />,
|
||||
background: {
|
||||
gradientStops: ['#7C3AED', '#EC4899'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AnimatedCircleConfig } from '@app/types/types';
|
||||
|
||||
/**
|
||||
* Unified circle background configuration used across all onboarding slides.
|
||||
* Only gradient colors change between slides, creating smooth transitions.
|
||||
*/
|
||||
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 270,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 24,
|
||||
duration: 4.5,
|
||||
offsetX: 18,
|
||||
offsetY: 14,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
opacity: 0.9,
|
||||
amplitude: 28,
|
||||
duration: 4.5,
|
||||
delay: 0.5,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const addGlowToElements = (selectors: string[]) => {
|
||||
selectors.forEach((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
if (selector === '[data-tour="settings-content-area"]') {
|
||||
element.classList.add('tour-content-glow');
|
||||
} else {
|
||||
element.classList.add('tour-nav-glow');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const removeAllGlows = () => {
|
||||
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
|
||||
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { TourStep } from '@app/components/onboarding/tourSteps';
|
||||
|
||||
interface UserStepActions {
|
||||
saveWorkbenchState: () => void;
|
||||
closeFilesModal: () => void;
|
||||
backToAllTools: () => void;
|
||||
selectCropTool: () => void;
|
||||
loadSampleFile: () => void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
openFilesModal: () => void;
|
||||
}
|
||||
|
||||
interface CreateUserStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: UserStepActions;
|
||||
}
|
||||
|
||||
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
},
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Title,
|
||||
Anchor,
|
||||
useMantineTheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
|
||||
@@ -15,6 +24,21 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
|
||||
const { refetch } = useAppConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const theme = useMantineTheme();
|
||||
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computedColorScheme === 'dark';
|
||||
const privacyHighlightStyles = {
|
||||
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
|
||||
borderRadius: theme.radius.md,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
letterSpacing: 0.3,
|
||||
};
|
||||
|
||||
const handleChoice = async (enableAnalytics: boolean) => {
|
||||
setLoading(true);
|
||||
@@ -60,7 +84,10 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
|
||||
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
|
||||
</Text>
|
||||
<Text size="sm" style={privacyHighlightStyles}>
|
||||
• {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} •
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
@@ -8,6 +8,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -20,6 +21,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { config } = useAppConfig();
|
||||
const licenseAlert = useLicenseAlert();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
@@ -64,8 +66,8 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Get isAdmin, runningEE, and loginEnabled from app config
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
// Get isAdmin and runningEE from app config
|
||||
const isAdmin = true // config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
@@ -138,6 +140,10 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isDisabled = item.disabled ?? false;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
const showPlanWarning =
|
||||
item.key === 'adminPlan' &&
|
||||
licenseAlert.active &&
|
||||
licenseAlert.audience === 'admin';
|
||||
|
||||
const navItemContent = (
|
||||
<div
|
||||
@@ -157,9 +163,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{showPlanWarning && (
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ color: 'var(--mantine-color-orange-7)' }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
@@ -20,7 +20,7 @@ export default function Footer({
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
const { showCookiePreferences } = useCookieConsentContext();
|
||||
|
||||
// Helper to check if a value is valid (not null/undefined/empty string)
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
type InfoBannerTone = 'info' | 'warning';
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
border: 'var(--mantine-color-blue-2)',
|
||||
text: 'var(--mantine-color-blue-9)',
|
||||
icon: 'var(--mantine-color-blue-6)',
|
||||
buttonColor: 'blue',
|
||||
},
|
||||
warning: {
|
||||
background: 'var(--mantine-color-orange-0)',
|
||||
border: 'var(--mantine-color-orange-3)',
|
||||
text: 'var(--mantine-color-orange-9)',
|
||||
icon: 'var(--mantine-color-orange-7)',
|
||||
buttonColor: 'orange',
|
||||
},
|
||||
};
|
||||
|
||||
interface InfoBannerProps {
|
||||
icon: string;
|
||||
message: string;
|
||||
buttonText: string;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick: () => void;
|
||||
onDismiss: () => void;
|
||||
onButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,55 +57,97 @@ interface InfoBannerProps {
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = 'check-circle-rounded',
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = 'info',
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = 'light',
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={0}
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
borderBottom: '1px solid var(--mantine-color-blue-2)',
|
||||
position: 'relative',
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
|
||||
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size="sm"
|
||||
style={{ color: textColor ?? toneStyle.text }}
|
||||
lineClamp={2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColor ?? toneStyle.buttonColor}
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={closeIconColor ? undefined : 'gray'}
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider, Menu } from "@mantine/core";
|
||||
import { ActionIcon, Stack, Divider, Menu, Indicator } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
@@ -34,6 +35,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const { startTour } = useOnboarding();
|
||||
const licenseAlert = useLicenseAlert();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -314,9 +316,27 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
);
|
||||
}
|
||||
|
||||
const buttonNode = renderNavButton(buttonConfig, index);
|
||||
const shouldShowSettingsBadge =
|
||||
buttonConfig.id === 'config' &&
|
||||
licenseAlert.active &&
|
||||
licenseAlert.audience === 'admin';
|
||||
|
||||
return (
|
||||
<React.Fragment key={buttonConfig.id}>
|
||||
{renderNavButton(buttonConfig, index)}
|
||||
{shouldShowSettingsBadge ? (
|
||||
<Indicator
|
||||
inline
|
||||
size={12}
|
||||
color="orange"
|
||||
position="top-end"
|
||||
offset={4}
|
||||
>
|
||||
{buttonNode}
|
||||
</Indicator>
|
||||
) : (
|
||||
buttonNode
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,16 +3,31 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import '@app/components/tools/ToolPanelModePrompt.css';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import { type ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
const ToolPanelModePrompt = () => {
|
||||
interface ToolPanelModePromptProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const {
|
||||
startTour,
|
||||
startAfterToolModeSelection,
|
||||
setStartAfterToolModeSelection,
|
||||
pendingTourRequest,
|
||||
} = useOnboarding();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = !!config?.isAdmin;
|
||||
|
||||
const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
|
||||
// Only show after the new 3-slide onboarding has been completed
|
||||
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowPrompt) {
|
||||
@@ -20,16 +35,56 @@ const ToolPanelModePrompt = () => {
|
||||
}
|
||||
}, [shouldShowPrompt]);
|
||||
|
||||
const resolveRequestedTourType = (): 'admin' | 'tools' => {
|
||||
if (pendingTourRequest?.type) {
|
||||
return pendingTourRequest.type;
|
||||
}
|
||||
if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
|
||||
return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
|
||||
}
|
||||
return isAdmin ? 'admin' : 'tools';
|
||||
};
|
||||
|
||||
const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
|
||||
if (!startAfterToolModeSelection) {
|
||||
return;
|
||||
}
|
||||
setStartAfterToolModeSelection(false);
|
||||
const targetType = resolveRequestedTourType();
|
||||
startTour(targetType, {
|
||||
skipToolPromptRequirement: true,
|
||||
source: 'tool-panel-mode-prompt',
|
||||
metadata: {
|
||||
...pendingTourRequest?.metadata,
|
||||
resumedFromToolPrompt: true,
|
||||
...(context?.selection ? { selection: context.selection } : {}),
|
||||
...(context?.dismissed ? { dismissed: true } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (mode: ToolPanelMode) => {
|
||||
setToolPanelMode(mode);
|
||||
updatePreference('defaultToolPanelMode', mode);
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
setOpened(false);
|
||||
|
||||
resumeDeferredTour({ selection: mode });
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
const defaultMode: ToolPanelMode = 'sidebar';
|
||||
if (toolPanelMode !== defaultMode) {
|
||||
setToolPanelMode(defaultMode);
|
||||
updatePreference('defaultToolPanelMode', defaultMode);
|
||||
}
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
setOpened(false);
|
||||
resumeDeferredTour({ dismissed: true });
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Centralized download URLs for Stirling PDF desktop installers
|
||||
export const DOWNLOAD_URLS = {
|
||||
WINDOWS: 'https://files.stirlingpdf.com/win-installer.exe',
|
||||
MAC_APPLE_SILICON: 'https://files.stirlingpdf.com/mac-installer.dmg',
|
||||
MAC_INTEL: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg',
|
||||
LINUX_DOCS: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/',
|
||||
} as const;
|
||||
|
||||
export const DOWNLOAD_BASE_URL = 'https://files.stirlingpdf.com/';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
|
||||
export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started';
|
||||
export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
|
||||
export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
|
||||
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
|
||||
|
||||
export interface ServerLicenseRequestPayload {
|
||||
licenseNotice?: Partial<LicenseNotice>;
|
||||
deferUntilTourComplete?: boolean;
|
||||
selfReportedAdmin?: boolean;
|
||||
}
|
||||
|
||||
export type UpgradeBannerTestScenario = 'friendly' | 'urgent-admin' | 'urgent-user' | null;
|
||||
|
||||
export interface UpgradeBannerTestPayload {
|
||||
scenario: UpgradeBannerTestScenario;
|
||||
}
|
||||
|
||||
export interface UpgradeBannerAlertPayload {
|
||||
active: boolean;
|
||||
audience?: 'admin' | 'user';
|
||||
totalUsers?: number | null;
|
||||
freeTierLimit?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
|
||||
|
||||
/**
|
||||
* Sleep utility for delays
|
||||
@@ -44,6 +45,8 @@ export interface AppConfig {
|
||||
activeSecurity?: boolean;
|
||||
dependenciesReady?: boolean;
|
||||
error?: string;
|
||||
isNewServer?: boolean;
|
||||
isNewUser?: boolean;
|
||||
}
|
||||
|
||||
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
||||
@@ -106,6 +109,15 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const testConfig = getSimulatedAppConfig();
|
||||
if (testConfig) {
|
||||
setConfig(testConfig);
|
||||
setFetchCount((prev) => prev + 1);
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt > 0) {
|
||||
const delay = initialDelay * Math.pow(2, attempt - 1);
|
||||
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useCookieConsent } from '@app/hooks/useCookieConsent';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface CookieConsentContextValue {
|
||||
isReady: boolean;
|
||||
showCookieConsent: () => void;
|
||||
showCookiePreferences: () => void;
|
||||
hasResponded: boolean;
|
||||
}
|
||||
|
||||
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(undefined);
|
||||
|
||||
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { config } = useAppConfig();
|
||||
const analyticsEnabled = config ? config.enableAnalytics !== false : false;
|
||||
const {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isInitialized,
|
||||
hasResponded,
|
||||
} = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
const value = useMemo<CookieConsentContextValue>(() => ({
|
||||
isReady: analyticsEnabled && isInitialized,
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
hasResponded,
|
||||
}), [analyticsEnabled, hasResponded, isInitialized, showCookieConsent, showCookiePreferences]);
|
||||
|
||||
return (
|
||||
<CookieConsentContext.Provider value={value}>
|
||||
{children}
|
||||
</CookieConsentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCookieConsentContext = (): CookieConsentContextValue => {
|
||||
const context = useContext(CookieConsentContext);
|
||||
if (!context) {
|
||||
throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,45 +1,107 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal';
|
||||
|
||||
export type TourType = 'tools' | 'admin';
|
||||
|
||||
export interface StartTourOptions {
|
||||
source?: string;
|
||||
skipToolPromptRequirement?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PendingTourRequest {
|
||||
type: TourType;
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
interface OnboardingContextValue {
|
||||
isOpen: boolean;
|
||||
currentStep: number;
|
||||
tourType: TourType;
|
||||
setCurrentStep: (step: number) => void;
|
||||
startTour: (type?: TourType) => void;
|
||||
startTour: (type?: TourType, options?: StartTourOptions) => void;
|
||||
closeTour: () => void;
|
||||
completeTour: () => void;
|
||||
resetTour: (type?: TourType) => void;
|
||||
showWelcomeModal: boolean;
|
||||
setShowWelcomeModal: (show: boolean) => void;
|
||||
startAfterToolModeSelection: boolean;
|
||||
setStartAfterToolModeSelection: (value: boolean) => void;
|
||||
pendingTourRequest: PendingTourRequest | null;
|
||||
clearPendingTourRequest: () => void;
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { updatePreference } = usePreferences();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [tourType, setTourType] = useState<TourType>('tools');
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
|
||||
const shouldShow = useShouldShowWelcomeModal();
|
||||
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
|
||||
const [pendingTourRequest, setPendingTourRequest] = useState<PendingTourRequest | null>(null);
|
||||
|
||||
// Auto-show welcome modal for first-time users
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
setShowWelcomeModal(true);
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
const startTour = useCallback((type: TourType = 'tools') => {
|
||||
const openTour = useCallback((type: TourType = 'tools') => {
|
||||
setTourType(type);
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback(
|
||||
(type: TourType = 'tools', options?: StartTourOptions) => {
|
||||
const requestedType = type ?? 'tools';
|
||||
const source = options?.source ?? 'unspecified';
|
||||
const metadata = options?.metadata;
|
||||
const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false;
|
||||
const toolPromptSeen = preferences.toolPanelModePromptSeen;
|
||||
const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode;
|
||||
const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode;
|
||||
const shouldDefer = !skipToolPromptRequirement && !hasToolPreference;
|
||||
|
||||
console.log('[onboarding] startTour invoked', {
|
||||
requestedType,
|
||||
source,
|
||||
toolPromptSeen,
|
||||
hasSelectedToolPanelMode,
|
||||
shouldDefer,
|
||||
hasPendingTourRequest: !!pendingTourRequest,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (shouldDefer) {
|
||||
setPendingTourRequest({
|
||||
type: requestedType,
|
||||
source,
|
||||
metadata,
|
||||
requestedAt: Date.now(),
|
||||
});
|
||||
setStartAfterToolModeSelection(true);
|
||||
console.log('[onboarding] deferring tour launch until tool panel mode selection completes', {
|
||||
requestedType,
|
||||
source,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingTourRequest) {
|
||||
console.log('[onboarding] clearing previous pending tour request before starting new tour', {
|
||||
previousRequest: pendingTourRequest,
|
||||
newType: requestedType,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
setPendingTourRequest(null);
|
||||
setStartAfterToolModeSelection(false);
|
||||
console.log('[onboarding] starting tour', {
|
||||
requestedType,
|
||||
source,
|
||||
});
|
||||
openTour(requestedType);
|
||||
},
|
||||
[openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode],
|
||||
);
|
||||
|
||||
const closeTour = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
@@ -56,6 +118,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
setIsOpen(true);
|
||||
}, [updatePreference]);
|
||||
|
||||
const clearPendingTourRequest = useCallback(() => {
|
||||
if (pendingTourRequest) {
|
||||
console.log('[onboarding] clearing pending tour request manually', {
|
||||
pendingTourRequest,
|
||||
});
|
||||
}
|
||||
setPendingTourRequest(null);
|
||||
setStartAfterToolModeSelection(false);
|
||||
}, [pendingTourRequest]);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
@@ -67,8 +139,10 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
closeTour,
|
||||
completeTour,
|
||||
resetTour,
|
||||
showWelcomeModal,
|
||||
setShowWelcomeModal,
|
||||
startAfterToolModeSelection,
|
||||
setStartAfterToolModeSelection,
|
||||
pendingTourRequest,
|
||||
clearPendingTourRequest,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -146,6 +146,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
|
||||
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
|
||||
updatePreference('defaultToolPanelMode', mode);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
}, [updatePreference]);
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ declare global {
|
||||
show: (show?: boolean) => void;
|
||||
acceptedCategory: (category: string) => boolean;
|
||||
acceptedService: (serviceName: string, category: string) => boolean;
|
||||
validConsent?: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,26 +20,50 @@ interface CookieConsentConfig {
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled = false,
|
||||
}: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hasRespondedInternal, setHasRespondedInternal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const markResponded = () => setHasRespondedInternal(true);
|
||||
const removeConsentListeners = () => {
|
||||
window.removeEventListener('cc:onFirstConsent', markResponded);
|
||||
window.removeEventListener('cc:onConsent', markResponded);
|
||||
window.removeEventListener('cc:onChange', markResponded);
|
||||
};
|
||||
|
||||
window.addEventListener('cc:onFirstConsent', markResponded);
|
||||
window.addEventListener('cc:onConsent', markResponded);
|
||||
window.addEventListener('cc:onChange', markResponded);
|
||||
|
||||
if (analyticsEnabled) {
|
||||
setHasRespondedInternal(window.CookieConsent?.validConsent?.() ?? false);
|
||||
}
|
||||
|
||||
if (!analyticsEnabled) {
|
||||
console.log('Cookie consent not enabled - analyticsEnabled is false');
|
||||
return;
|
||||
setHasRespondedInternal(false);
|
||||
return () => {
|
||||
removeConsentListeners();
|
||||
};
|
||||
}
|
||||
|
||||
// Prevent double initialization
|
||||
if (window.CookieConsent) {
|
||||
setIsInitialized(true);
|
||||
// Force show the modal if it exists but isn't visible
|
||||
setTimeout(() => {
|
||||
window.CookieConsent?.show();
|
||||
}, 100);
|
||||
return;
|
||||
if (window.CookieConsent.validConsent?.()) {
|
||||
markResponded();
|
||||
}
|
||||
return () => {
|
||||
removeConsentListeners();
|
||||
};
|
||||
}
|
||||
|
||||
// Load the cookie consent CSS files first
|
||||
@@ -116,7 +141,7 @@ export const useCookieConsent = ({
|
||||
// Initialize cookie consent with full configuration
|
||||
try {
|
||||
window.CookieConsent.run({
|
||||
autoShow: true,
|
||||
autoShow: false,
|
||||
hideFromBots: false,
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
@@ -202,18 +227,21 @@ export const useCookieConsent = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onFirstConsent: markResponded,
|
||||
onConsent: markResponded,
|
||||
onChange: markResponded,
|
||||
});
|
||||
|
||||
// Force show after initialization
|
||||
setTimeout(() => {
|
||||
window.CookieConsent?.show();
|
||||
}, 200);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing CookieConsent:', error);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
if (window.CookieConsent?.validConsent?.()) {
|
||||
markResponded();
|
||||
} else {
|
||||
setHasRespondedInternal(false);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
};
|
||||
|
||||
@@ -224,6 +252,8 @@ export const useCookieConsent = ({
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup event listeners
|
||||
removeConsentListeners();
|
||||
// Cleanup script and CSS when component unmounts
|
||||
if (document.head.contains(script)) {
|
||||
document.head.removeChild(script);
|
||||
@@ -237,11 +267,17 @@ export const useCookieConsent = ({
|
||||
};
|
||||
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
|
||||
|
||||
const showCookiePreferences = () => {
|
||||
const showCookieConsent = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
window.CookieConsent?.show();
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
const showCookiePreferences = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
window.CookieConsent?.show(true);
|
||||
}
|
||||
};
|
||||
}, [isInitialized]);
|
||||
|
||||
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
|
||||
if (typeof window === 'undefined' || !window.CookieConsent) {
|
||||
@@ -250,8 +286,13 @@ export const useCookieConsent = ({
|
||||
return window.CookieConsent.acceptedService(service, category);
|
||||
}, []);
|
||||
|
||||
const effectiveHasResponded = analyticsEnabled ? hasRespondedInternal : true;
|
||||
|
||||
return {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isServiceAccepted
|
||||
isServiceAccepted,
|
||||
isInitialized,
|
||||
hasResponded: effectiveHasResponded,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
UPGRADE_BANNER_ALERT_EVENT,
|
||||
type UpgradeBannerAlertPayload,
|
||||
} from '@app/constants/events';
|
||||
|
||||
export interface LicenseAlertState {
|
||||
active: boolean;
|
||||
audience: 'admin' | 'user' | null;
|
||||
totalUsers: number | null;
|
||||
freeTierLimit: number;
|
||||
}
|
||||
|
||||
const defaultState: LicenseAlertState = {
|
||||
active: false,
|
||||
audience: null,
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
};
|
||||
|
||||
export function useLicenseAlert(): LicenseAlertState {
|
||||
const [state, setState] = useState<LicenseAlertState>(defaultState);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleAlert = (event: Event) => {
|
||||
const detail = (event as CustomEvent<UpgradeBannerAlertPayload>).detail;
|
||||
if (detail?.active) {
|
||||
setState({
|
||||
active: true,
|
||||
audience: detail.audience ?? 'user',
|
||||
totalUsers:
|
||||
typeof detail.totalUsers === 'number' ? detail.totalUsers : null,
|
||||
freeTierLimit: detail.freeTierLimit ?? 5,
|
||||
});
|
||||
} else {
|
||||
setState(defaultState);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type OS =
|
||||
| 'windows'
|
||||
| 'mac-intel'
|
||||
| 'mac-apple'
|
||||
| 'linux-x64'
|
||||
| 'linux-arm64'
|
||||
| 'ios'
|
||||
| 'android'
|
||||
| 'unknown';
|
||||
|
||||
function parseUA(ua: string): OS {
|
||||
const uaLower = ua.toLowerCase();
|
||||
|
||||
// iOS (includes iPadOS masquerading as Mac in some cases)
|
||||
const isIOS = /iphone|ipad|ipod/.test(uaLower) || (ua.includes('Macintosh') && typeof window !== 'undefined' && 'ontouchstart' in window);
|
||||
if (isIOS) return 'ios';
|
||||
|
||||
if (/android/.test(uaLower)) return 'android';
|
||||
if (/windows nt/.test(uaLower)) return 'windows';
|
||||
if (/mac os x/.test(uaLower)) {
|
||||
// Default to Intel; refine via hints below
|
||||
let detected: OS = 'mac-intel';
|
||||
// Safari on Apple Silicon sometimes exposes both tokens
|
||||
if (ua.includes('Apple') && ua.includes('ARM')) {
|
||||
detected = 'mac-apple';
|
||||
}
|
||||
return detected; // will be further refined via Client Hints if available
|
||||
}
|
||||
if (/linux|x11/.test(uaLower)) return 'linux-x64';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function useOs(): OS {
|
||||
const [os, setOs] = useState<OS>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function detect() {
|
||||
// Start with UA fallback
|
||||
let detected: OS = parseUA(navigator.userAgent);
|
||||
|
||||
// Try Client Hints for better platform + architecture
|
||||
const uaData = (navigator as any).userAgentData;
|
||||
if (uaData?.getHighEntropyValues) {
|
||||
try {
|
||||
const { platform, architecture, bitness } = await uaData.getHighEntropyValues([
|
||||
'platform',
|
||||
'architecture',
|
||||
'bitness',
|
||||
'platformVersion',
|
||||
]);
|
||||
|
||||
const plat = (platform || '').toLowerCase();
|
||||
if (plat.includes('windows')) detected = 'windows';
|
||||
else if (plat.includes('ios')) detected = 'ios';
|
||||
else if (plat.includes('android')) detected = 'android';
|
||||
else if (plat.includes('mac')) {
|
||||
// CH “architecture” is often "arm" on Apple Silicon
|
||||
detected = architecture?.toLowerCase().includes('arm') ? 'mac-apple' : 'mac-intel';
|
||||
} else if (plat.includes('linux') || plat.includes('chrome os')) {
|
||||
const archLower = (architecture || '').toLowerCase();
|
||||
const isArm = archLower.includes('arm') || (bitness === '32' && /aarch|arm/.test(architecture || ''));
|
||||
detected = isArm ? 'linux-arm64' : 'linux-x64';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
// Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
|
||||
// Keep detected as-is (often 'mac-intel').
|
||||
}
|
||||
|
||||
if (!cancelled) setOs(detected);
|
||||
}
|
||||
|
||||
detect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return os;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
|
||||
const FREE_TIER_LIMIT = 5;
|
||||
|
||||
type UserCountSource = 'admin' | 'estimate' | 'unknown';
|
||||
|
||||
export type ServerScenarioKey =
|
||||
| 'unknown'
|
||||
| 'licensed'
|
||||
| 'no-login-user-under-limit-no-license'
|
||||
| 'no-login-admin-under-limit-no-license'
|
||||
| 'no-login-user-over-limit-no-license'
|
||||
| 'no-login-admin-over-limit-no-license'
|
||||
| 'login-user-under-limit-no-license'
|
||||
| 'login-admin-under-limit-no-license'
|
||||
| 'login-user-over-limit-no-license'
|
||||
| 'login-admin-over-limit-no-license';
|
||||
|
||||
export interface ServerExperienceValue {
|
||||
loginEnabled: boolean;
|
||||
configIsAdmin: boolean;
|
||||
effectiveIsAdmin: boolean;
|
||||
selfReportedAdmin: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isNewServer: boolean | null;
|
||||
isNewUser: boolean | null;
|
||||
premiumEnabled: boolean | null;
|
||||
license: string | undefined;
|
||||
runningProOrHigher: boolean | undefined;
|
||||
runningEE: boolean | undefined;
|
||||
hasPaidLicense: boolean;
|
||||
licenseKeyValid: boolean | null;
|
||||
licenseLoading: boolean;
|
||||
licenseInfoAvailable: boolean;
|
||||
totalUsers: number | null;
|
||||
weeklyActiveUsers: number | null;
|
||||
userCountLoading: boolean;
|
||||
userCountError: string | null;
|
||||
userCountSource: UserCountSource;
|
||||
userCountResolved: boolean;
|
||||
overFreeTierLimit: boolean | null;
|
||||
freeTierLimit: number;
|
||||
refreshUserCounts: () => Promise<void>;
|
||||
setSelfReportedAdmin: (value: boolean) => void;
|
||||
scenarioKey: ServerScenarioKey;
|
||||
}
|
||||
|
||||
function readSelfReportedAdmin(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerExperience(): ServerExperienceValue {
|
||||
const { config } = useAppConfig();
|
||||
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(readSelfReportedAdmin);
|
||||
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
const configIsAdmin = Boolean(config?.isAdmin);
|
||||
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
|
||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||
setSelfReportedAdminState(value);
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage write failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === SELF_REPORTED_ADMIN_KEY) {
|
||||
setSelfReportedAdminState(event.newValue === 'true');
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.isNewServer && !loginEnabled && !selfReportedAdmin) {
|
||||
setSelfReportedAdmin(true);
|
||||
}
|
||||
}, [config?.isNewServer, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
|
||||
|
||||
const scenarioKey: ServerScenarioKey = useMemo(() => {
|
||||
if (hasPaidLicense) {
|
||||
return 'licensed';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [hasPaidLicense]);
|
||||
|
||||
const value = useMemo<ServerExperienceValue>(() => ({
|
||||
loginEnabled,
|
||||
configIsAdmin,
|
||||
effectiveIsAdmin,
|
||||
selfReportedAdmin,
|
||||
isAuthenticated: false,
|
||||
isNewServer: config?.isNewServer ?? null,
|
||||
isNewUser: config?.isNewUser ?? null,
|
||||
premiumEnabled: config?.premiumEnabled ?? null,
|
||||
license: config?.license,
|
||||
runningProOrHigher: config?.runningProOrHigher,
|
||||
runningEE: config?.runningEE,
|
||||
hasPaidLicense,
|
||||
licenseKeyValid: config?.premiumEnabled ?? null,
|
||||
licenseLoading: false,
|
||||
licenseInfoAvailable: false,
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
userCountLoading: false,
|
||||
userCountError: null,
|
||||
userCountSource: 'unknown',
|
||||
userCountResolved: false,
|
||||
overFreeTierLimit: null,
|
||||
freeTierLimit: FREE_TIER_LIMIT,
|
||||
refreshUserCounts: async () => {},
|
||||
setSelfReportedAdmin,
|
||||
scenarioKey,
|
||||
}), [
|
||||
config?.isNewServer,
|
||||
config?.isNewUser,
|
||||
config?.license,
|
||||
config?.premiumEnabled,
|
||||
config?.runningEE,
|
||||
config?.runningProOrHigher,
|
||||
configIsAdmin,
|
||||
effectiveIsAdmin,
|
||||
hasPaidLicense,
|
||||
loginEnabled,
|
||||
scenarioKey,
|
||||
selfReportedAdmin,
|
||||
setSelfReportedAdmin,
|
||||
]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ export function useShouldShowWelcomeModal(): boolean {
|
||||
const { preferences } = usePreferences();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return !preferences.hasCompletedOnboarding
|
||||
return preferences.hasSeenIntroOnboarding
|
||||
&& !preferences.hasCompletedOnboarding
|
||||
&& preferences.toolPanelModePromptSeen
|
||||
&& !isMobile;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useLogoPath } from "@app/hooks/useLogoPath";
|
||||
import { useCookieConsentContext } from "@app/contexts/CookieConsentContext";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
@@ -22,7 +23,6 @@ import FileManager from "@app/components/FileManager";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
@@ -49,6 +49,7 @@ export default function HomePage() {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { config } = useAppConfig();
|
||||
const { hasResponded: cookieConsentResponded } = useCookieConsentContext();
|
||||
const isMobile = useIsMobile();
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
@@ -63,10 +64,10 @@ export default function HomePage() {
|
||||
|
||||
// Show admin analytics choice modal if analytics settings not configured
|
||||
useEffect(() => {
|
||||
if (config && config.enableAnalytics === null) {
|
||||
if (config && config.enableAnalytics === null && cookieConsentResponded) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [config]);
|
||||
}, [config, cookieConsentResponded]);
|
||||
|
||||
// Auto-switch to viewer when going from 0 to 1 file
|
||||
useEffect(() => {
|
||||
@@ -187,7 +188,6 @@ export default function HomePage() {
|
||||
opened={showAnalyticsModal}
|
||||
onClose={() => setShowAnalyticsModal(false)}
|
||||
/>
|
||||
<ToolPanelModePrompt />
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
<div className="mobile-toggle">
|
||||
|
||||
@@ -7,8 +7,11 @@ export interface UserPreferences {
|
||||
defaultToolPanelMode: ToolPanelMode;
|
||||
theme: ThemeMode;
|
||||
toolPanelModePromptSeen: boolean;
|
||||
hasSelectedToolPanelMode: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hasSeenIntroOnboarding: boolean;
|
||||
hasSeenCookieBanner: boolean;
|
||||
hideUnavailableTools: boolean;
|
||||
hideUnavailableConversions: boolean;
|
||||
}
|
||||
@@ -19,8 +22,11 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
theme: getSystemTheme(),
|
||||
toolPanelModePromptSeen: false,
|
||||
hasSelectedToolPanelMode: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
hasSeenIntroOnboarding: false,
|
||||
hasSeenCookieBanner: false,
|
||||
hideUnavailableTools: false,
|
||||
hideUnavailableConversions: false,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DOWNLOAD_BASE_URL } from '@app/constants/downloads';
|
||||
|
||||
export interface UpdateSummary {
|
||||
latest_version: string | null;
|
||||
latest_stable_version?: string | null;
|
||||
@@ -71,11 +73,9 @@ export class UpdateService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://files.stirlingpdf.com/';
|
||||
|
||||
// Determine file based on machine type and security
|
||||
if (machineInfo.machineType === 'Server-jar') {
|
||||
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
|
||||
return DOWNLOAD_BASE_URL + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
|
||||
}
|
||||
|
||||
// Client installations
|
||||
@@ -84,11 +84,11 @@ export class UpdateService {
|
||||
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
|
||||
|
||||
if (os === 'unix') {
|
||||
return baseUrl + os + type + '.jar';
|
||||
return DOWNLOAD_BASE_URL + os + type + '.jar';
|
||||
} else if (os === 'win') {
|
||||
return baseUrl + os + '-installer.exe';
|
||||
return DOWNLOAD_BASE_URL + os + '-installer.exe';
|
||||
} else if (os === 'mac') {
|
||||
return baseUrl + os + '-installer.dmg';
|
||||
return DOWNLOAD_BASE_URL + os + '-installer.dmg';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,6 +328,19 @@
|
||||
--compare-page-label-fg: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Onboarding (light mode) */
|
||||
:root {
|
||||
--onboarding-title: #0A0A0A;
|
||||
--onboarding-body: #4A5565;
|
||||
--onboarding-primary-button-bg: #101828;
|
||||
--onboarding-primary-button-text: #FFFFFF;
|
||||
--onboarding-secondary-button-bg: #FFFFFF;
|
||||
--onboarding-secondary-button-text: #6A7282;
|
||||
--onboarding-secondary-button-border: #E5E5E5;
|
||||
--onboarding-step-active: #1E2939;
|
||||
--onboarding-step-inactive: #D1D5DC;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
/* Dark theme gray scale (inverted) */
|
||||
--gray-50: 17 24 39;
|
||||
@@ -548,6 +561,16 @@
|
||||
--modal-content-bg: #2A2F36;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Onboarding (dark mode) */
|
||||
--onboarding-title: #F9FAFB;
|
||||
--onboarding-body: #D1D5DB;
|
||||
--onboarding-primary-button-bg: #FFFFFF;
|
||||
--onboarding-primary-button-text: #0B1220;
|
||||
--onboarding-secondary-button-bg: transparent;
|
||||
--onboarding-secondary-button-text: #E5E7EB;
|
||||
--onboarding-secondary-button-border: #3A4047;
|
||||
--onboarding-step-active: #E5E7EB;
|
||||
--onboarding-step-inactive: #4B5563;
|
||||
/* API Keys section colors (dark mode) */
|
||||
--api-keys-card-bg: #2A2F36;
|
||||
--api-keys-card-border: #3A4047;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface LicenseInfo {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}
|
||||
|
||||
interface WauResponse {
|
||||
trackingSince: string;
|
||||
daysOnline: number;
|
||||
totalUniqueBrowsers: number;
|
||||
weeklyActiveUsers: number;
|
||||
}
|
||||
|
||||
interface AdminUsageResponse {
|
||||
totalUsers?: number;
|
||||
}
|
||||
|
||||
interface SimulationScenario {
|
||||
label: string;
|
||||
appConfig: AppConfig;
|
||||
wau?: WauResponse;
|
||||
adminUsage?: AdminUsageResponse;
|
||||
licenseInfo: LicenseInfo;
|
||||
}
|
||||
|
||||
const DEV_TESTING_MODE = false;
|
||||
const SIMULATION_INDEX = 0;
|
||||
|
||||
const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
licenseType: 'NORMAL',
|
||||
enabled: false,
|
||||
maxUsers: 5,
|
||||
hasKey: false,
|
||||
};
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.0.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
premiumEnabled: false,
|
||||
runningProOrHigher: false,
|
||||
runningEE: false,
|
||||
enableLogin: false,
|
||||
activeSecurity: false,
|
||||
languages: [],
|
||||
contextPath: '/',
|
||||
license: 'NORMAL',
|
||||
baseUrl: 'http://localhost',
|
||||
enableEmailInvites: true,
|
||||
};
|
||||
|
||||
const BASE_LOGIN_CONFIG: AppConfig = {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
enableLogin: true,
|
||||
activeSecurity: true,
|
||||
};
|
||||
|
||||
const SIMULATION_SCENARIOS: SimulationScenario[] = [
|
||||
{
|
||||
label: 'no-login-user-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-11-18T23:20:12.520884200Z',
|
||||
daysOnline: 0,
|
||||
totalUniqueBrowsers: 3,
|
||||
weeklyActiveUsers: 3,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-admin-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-10-01T00:00:00Z',
|
||||
daysOnline: 14,
|
||||
totalUniqueBrowsers: 4,
|
||||
weeklyActiveUsers: 4,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-user-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-09-01T00:00:00Z',
|
||||
daysOnline: 30,
|
||||
totalUniqueBrowsers: 12,
|
||||
weeklyActiveUsers: 9,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-admin-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-08-15T00:00:00Z',
|
||||
daysOnline: 45,
|
||||
totalUniqueBrowsers: 18,
|
||||
weeklyActiveUsers: 12,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-user-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: false,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 3,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-admin-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: true,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 4,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-user-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: false,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 12,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-admin-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: true,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 57,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
];
|
||||
|
||||
function getActiveScenario(): SimulationScenario | null {
|
||||
if (!DEV_TESTING_MODE) {
|
||||
return null;
|
||||
}
|
||||
const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
|
||||
if (!scenario) {
|
||||
console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
|
||||
return null;
|
||||
}
|
||||
console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
export function getSimulatedAppConfig(): AppConfig | null {
|
||||
return getActiveScenario()?.appConfig ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedWauResponse(): WauResponse | null {
|
||||
return getActiveScenario()?.wau ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedAdminUsage(): AdminUsageResponse | null {
|
||||
return getActiveScenario()?.adminUsage ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedLicenseInfo(): LicenseInfo | null {
|
||||
return getActiveScenario()?.licenseInfo ?? null;
|
||||
}
|
||||
|
||||
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface AnimatedCircleConfig {
|
||||
size: number;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
blur?: number;
|
||||
position: 'bottom-left' | 'top-right';
|
||||
amplitude?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export interface AnimatedSlideBackgroundProps {
|
||||
gradientStops: [string, string];
|
||||
circles: AnimatedCircleConfig[];
|
||||
}
|
||||
|
||||
export interface SlideConfig {
|
||||
key: string;
|
||||
title: ReactNode;
|
||||
body: ReactNode;
|
||||
background: AnimatedSlideBackgroundProps;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface LicenseNotice {
|
||||
totalUsers: number | null;
|
||||
freeTierLimit: number;
|
||||
isOverLimit: boolean;
|
||||
requiresLicense: boolean;
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
|
||||
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
|
||||
|
||||
export function DesktopBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
|
||||
useEffect(() => {
|
||||
setBanner(<DefaultAppBanner />);
|
||||
setBanner(
|
||||
<>
|
||||
<UpgradeBanner />
|
||||
<DefaultAppBanner />
|
||||
</>,
|
||||
);
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [setBanner]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -2,8 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||
|
||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||
return (
|
||||
@@ -13,12 +14,14 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
>
|
||||
<AuthProvider>
|
||||
<LicenseProvider>
|
||||
<CheckoutProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<UpgradeBanner />
|
||||
{children}
|
||||
</UpdateSeatsProvider>
|
||||
</CheckoutProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<ServerExperienceProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBannerInitializer />
|
||||
{children}
|
||||
</CheckoutProvider>
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
</LicenseProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { userManagementService } from '@app/services/userManagementService';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface InviteMembersModalProps {
|
||||
opened: boolean;
|
||||
@@ -31,6 +32,7 @@ interface InviteMembersModalProps {
|
||||
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
|
||||
@@ -45,6 +47,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
|
||||
premiumEnabled: boolean;
|
||||
totalUsers: number;
|
||||
} | null>(null);
|
||||
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
|
||||
|
||||
// Form state for direct invite
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
@@ -242,6 +245,21 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGoToPlan = () => {
|
||||
handleClose();
|
||||
navigate('/settings/adminPlan');
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (inviteMode === 'email') {
|
||||
handleEmailInvite();
|
||||
} else if (inviteMode === 'link') {
|
||||
handleGenerateInviteLink();
|
||||
} else {
|
||||
handleInviteUser();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -281,16 +299,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
|
||||
{licenseInfo && (
|
||||
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
||||
<Text size="sm" fw={500}>
|
||||
{licenseInfo.availableSlots > 0
|
||||
? t('workspace.people.license.slotsAvailable', {
|
||||
count: licenseInfo.availableSlots,
|
||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
||||
})
|
||||
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
</Text>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
||||
<Text size="sm" fw={500}>
|
||||
{licenseInfo.availableSlots > 0
|
||||
? t('workspace.people.license.slotsAvailable', {
|
||||
count: licenseInfo.availableSlots,
|
||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
||||
})
|
||||
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
</Text>
|
||||
</Group>
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Button size="xs" variant="light" onClick={handleGoToPlan}>
|
||||
{t('workspace.people.actions.upgrade', 'Upgrade')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.license.currentUsage', {
|
||||
@@ -495,8 +520,8 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
|
||||
loading={processing}
|
||||
onClick={handlePrimaryAction}
|
||||
loading={!hasNoSlots && processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
|
||||
@@ -1,143 +1,391 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import { mapLicenseToTier } from '@app/services/licenseService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
UPGRADE_BANNER_TEST_EVENT,
|
||||
type UpgradeBannerTestPayload,
|
||||
type UpgradeBannerTestScenario,
|
||||
UPGRADE_BANNER_ALERT_EVENT,
|
||||
} from '@core/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
/**
|
||||
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
|
||||
*
|
||||
* This component demonstrates:
|
||||
* - How to check authentication status with useAuth()
|
||||
* - How to check license status with licenseService
|
||||
* - How to open checkout modal with useCheckout()
|
||||
* - How to persist dismissal state with localStorage
|
||||
*
|
||||
* To remove this banner:
|
||||
* 1. Remove the import and component from AppProviders.tsx
|
||||
* 2. Delete this file
|
||||
*/
|
||||
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { hasResponded: cookieChoiceMade } = useCookieConsentContext();
|
||||
const { isOpen: tourOpen } = useOnboarding();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo, loading: licenseLoading } = useLicense();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const {
|
||||
totalUsers,
|
||||
userCountResolved,
|
||||
userCountLoading,
|
||||
effectiveIsAdmin: configIsAdmin,
|
||||
hasPaidLicense,
|
||||
licenseLoading,
|
||||
freeTierLimit,
|
||||
overFreeTierLimit,
|
||||
scenarioKey,
|
||||
} = useServerExperience();
|
||||
const [sessionBlocked, setSessionBlocked] = useState(true);
|
||||
const [friendlyVisible, setFriendlyVisible] = useState(false);
|
||||
const isDev = import.meta.env.DEV;
|
||||
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
|
||||
|
||||
// Check if user should see the banner
|
||||
// Track onboarding session flag so we don't show banner if onboarding ran this load
|
||||
useEffect(() => {
|
||||
// Don't show if not logged in
|
||||
if (!user) {
|
||||
setIsVisible(false);
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show if Supabase is not configured (no checkout available)
|
||||
if (!isSupabaseConfigured) {
|
||||
setIsVisible(false);
|
||||
const evaluateBlock = () => {
|
||||
const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true';
|
||||
setSessionBlocked(blocked);
|
||||
};
|
||||
|
||||
evaluateBlock();
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
evaluateBlock();
|
||||
}, 1000);
|
||||
|
||||
const handleOnboardingEvent = () => {
|
||||
evaluateBlock();
|
||||
};
|
||||
|
||||
window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDev || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show while license is loading
|
||||
if (licenseLoading) {
|
||||
const handleTestEvent = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
|
||||
setTestScenario(detail?.scenario ?? null);
|
||||
|
||||
if (detail?.scenario === 'friendly') {
|
||||
setFriendlyVisible(true);
|
||||
} else if (!detail?.scenario) {
|
||||
setFriendlyVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
|
||||
};
|
||||
}, [isDev]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
|
||||
const scenario = isDev ? testScenario : null;
|
||||
const scenarioIsFriendly = scenario === 'friendly';
|
||||
const scenarioIsUrgentUser = scenario === 'urgent-user';
|
||||
|
||||
const userCountKnown = typeof totalUsers === 'number';
|
||||
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
|
||||
const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
|
||||
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const derivedIsAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: isAdmin;
|
||||
const derivedHasPaidLicense =
|
||||
scenarioKey === 'licensed'
|
||||
? true
|
||||
: scenarioKey === 'unknown'
|
||||
? hasPaidLicense
|
||||
: false;
|
||||
const derivedIsUnderLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('under-limit')
|
||||
: isUnderLimit === true;
|
||||
const derivedIsOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: isOverLimit === true;
|
||||
|
||||
const effectiveIsAdmin = scenario
|
||||
? scenarioIsUrgentUser
|
||||
? false
|
||||
: true
|
||||
: derivedIsAdmin;
|
||||
const effectiveTotalUsers =
|
||||
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
|
||||
const effectiveTotalUsersLoaded = scenario != null ? true : baseTotalUsersLoaded;
|
||||
const effectiveHasPaidLicense = scenario != null ? false : derivedHasPaidLicense;
|
||||
const effectiveIsUnderLimit =
|
||||
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
|
||||
const effectiveIsOverLimit =
|
||||
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
|
||||
|
||||
const isDerivedAdmin = scenario
|
||||
? !scenarioIsUrgentUser
|
||||
: scenarioKey === 'login-user-over-limit-no-license'
|
||||
? false
|
||||
: effectiveIsAdmin;
|
||||
|
||||
const shouldShowFriendlyBase = Boolean(
|
||||
isDerivedAdmin &&
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveIsUnderLimit &&
|
||||
effectiveTotalUsersLoaded,
|
||||
);
|
||||
const shouldShowUrgentBase = Boolean(
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
(effectiveIsOverLimit || scenarioKey === 'login-user-over-limit-no-license'),
|
||||
);
|
||||
|
||||
const shouldEvaluateFriendly = scenario
|
||||
? scenarioIsFriendly
|
||||
: Boolean(
|
||||
shouldShowFriendlyBase &&
|
||||
!licenseLoading &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
const shouldEvaluateUrgent = scenario
|
||||
? Boolean(scenario && !scenarioIsFriendly)
|
||||
: Boolean(
|
||||
shouldShowUrgentBase &&
|
||||
!licenseLoading &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if banner was dismissed
|
||||
const dismissed = localStorage.getItem('upgradeBannerDismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsVisible(false);
|
||||
if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
|
||||
window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
}
|
||||
}, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scenario === 'friendly') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check license status from global context
|
||||
const tier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
// Show banner only for free tier users
|
||||
if (tier === 'free' || tier === null) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
// Auto-hide banner if user upgrades
|
||||
setIsVisible(false);
|
||||
if (!shouldEvaluateFriendly) {
|
||||
setFriendlyVisible(false);
|
||||
return;
|
||||
}
|
||||
}, [user, licenseInfo, licenseLoading]);
|
||||
|
||||
// Handle dismiss
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('upgradeBannerDismissed', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
if (friendlyVisible || typeof window === 'undefined' || userCountLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle upgrade button click
|
||||
const handleUpgrade = () => {
|
||||
openCheckout('server', {
|
||||
// Currency auto-detected from locale in CheckoutContext
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
// Banner will auto-hide on next render when license is detected
|
||||
setIsVisible(false);
|
||||
},
|
||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
|
||||
const now = Date.now();
|
||||
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
|
||||
setFriendlyVisible(due);
|
||||
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = shouldEvaluateUrgent
|
||||
? {
|
||||
active: true,
|
||||
audience: effectiveIsAdmin ? 'admin' : 'user',
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
}
|
||||
: { active: false };
|
||||
|
||||
console.debug('[UpgradeBanner] Dispatching alert event', {
|
||||
shouldEvaluateUrgent,
|
||||
detail,
|
||||
totalUsers: effectiveTotalUsers,
|
||||
freeTierLimit,
|
||||
effectiveIsAdmin,
|
||||
effectiveHasPaidLicense,
|
||||
userCountLoaded: effectiveTotalUsersLoaded,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
|
||||
);
|
||||
}, [shouldEvaluateUrgent, effectiveIsAdmin, effectiveTotalUsers, scenario, freeTierLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail: { active: false } }),
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recordFriendlyLastShown = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (friendlyVisible) {
|
||||
recordFriendlyLastShown();
|
||||
}
|
||||
}, [friendlyVisible, recordFriendlyLastShown]);
|
||||
|
||||
const handleUpgrade = () => {
|
||||
recordFriendlyLastShown();
|
||||
|
||||
const hideBanner = () => setFriendlyVisible(false);
|
||||
const navigateFallback = () => {
|
||||
navigate('/settings/adminPlan');
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
try {
|
||||
openCheckout('server', {
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
hideBanner();
|
||||
},
|
||||
onError: () => {
|
||||
navigateFallback();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UpgradeBanner] Failed to open checkout, redirecting instead', error);
|
||||
navigateFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep legacy behavior so banner disappears once the user initiates checkout
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
// Don't render anything if loading or not visible
|
||||
if (licenseLoading || !isVisible) {
|
||||
const handleFriendlyDismiss = () => {
|
||||
recordFriendlyLastShown();
|
||||
setFriendlyVisible(false);
|
||||
};
|
||||
|
||||
const handleSeeInfo = () => {
|
||||
if (typeof window === 'undefined' || !effectiveIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail: ServerLicenseRequestPayload = {
|
||||
licenseNotice: {
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
isOverLimit: effectiveIsOverLimit ?? false,
|
||||
},
|
||||
selfReportedAdmin: true,
|
||||
deferUntilTourComplete: false,
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
|
||||
);
|
||||
};
|
||||
|
||||
const renderUrgentBanner = () => {
|
||||
if (!shouldEvaluateUrgent) {
|
||||
console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
|
||||
return null;
|
||||
}
|
||||
console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
|
||||
totalUsers: effectiveTotalUsers,
|
||||
freeTierLimit,
|
||||
effectiveIsAdmin,
|
||||
effectiveHasPaidLicense,
|
||||
});
|
||||
|
||||
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
|
||||
|
||||
const attentionMessage = effectiveIsAdmin
|
||||
? t(
|
||||
'upgradeBanner.attentionBodyAdmin',
|
||||
'Review the license requirements to keep this server compliant.',
|
||||
)
|
||||
: t(
|
||||
'upgradeBanner.attentionBody',
|
||||
'Your admin needs to sign in to see more info. Please contact them immediately.',
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t('upgradeBanner.attentionTitle', 'This server needs admin attention')}
|
||||
message={attentionMessage}
|
||||
buttonText={buttonText}
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={buttonText ? handleSeeInfo : undefined}
|
||||
dismissible={false}
|
||||
minHeight={60}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!friendlyVisible && !shouldEvaluateUrgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9}>
|
||||
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
size="sm"
|
||||
onClick={handleUpgrade}
|
||||
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="white"
|
||||
size="lg"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
<>
|
||||
{friendlyVisible && (
|
||||
<InfoBanner
|
||||
icon="stars-rounded"
|
||||
title={t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
message={t(
|
||||
'upgradeBanner.message',
|
||||
'Get the most out of Stirling PDF with unlimited users and advanced features.',
|
||||
)}
|
||||
buttonText={t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={handleUpgrade}
|
||||
onDismiss={handleFriendlyDismiss}
|
||||
show={friendlyVisible}
|
||||
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
borderColor="transparent"
|
||||
textColor="#fff"
|
||||
iconColor="#fff"
|
||||
closeIconColor="#fff"
|
||||
buttonVariant="white"
|
||||
buttonColor="blue"
|
||||
minHeight={64}
|
||||
/>
|
||||
)}
|
||||
{renderUrgentBanner()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
|
||||
|
||||
export function UpgradeBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
|
||||
useEffect(() => {
|
||||
setBanner(<UpgradeBanner />);
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [setBanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+57
-2
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
@@ -9,6 +9,9 @@ import AvailablePlansSection from '@app/components/shared/config/configSections/
|
||||
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
|
||||
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';
|
||||
|
||||
@@ -25,10 +28,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
|
||||
const [savingLicense, setSavingLicense] = useState(false);
|
||||
const { plans, loading, error, refetch } = usePlans(currency);
|
||||
const licenseAlert = useLicenseAlert();
|
||||
|
||||
// Check if we should use static version
|
||||
useEffect(() => {
|
||||
// Check if Stripe and Supabase are configured
|
||||
// Check if Stripe is configured
|
||||
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey || !isSupabaseConfigured || error) {
|
||||
setUseStaticVersion(true);
|
||||
@@ -146,6 +150,23 @@ const AdminPlanSection: React.FC = () => {
|
||||
[openCheckout, currency, refetch, licenseInfo, t]
|
||||
);
|
||||
|
||||
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
|
||||
const formattedUserCount = useMemo(() => {
|
||||
if (licenseAlert.totalUsers == null) {
|
||||
return t('plan.licenseWarning.overLimit', 'more than {{limit}}', {
|
||||
limit: licenseAlert.freeTierLimit,
|
||||
});
|
||||
}
|
||||
return licenseAlert.totalUsers.toLocaleString();
|
||||
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
|
||||
|
||||
const scrollToPlans = useCallback(() => {
|
||||
const el = document.getElementById('available-plans-section');
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show static version if Stripe is not configured or there's an error
|
||||
if (useStaticVersion) {
|
||||
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
|
||||
@@ -175,6 +196,40 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{shouldShowLicenseWarning && (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t('plan.licenseWarning.title', 'Free self-hosted limit reached')}
|
||||
message={t('plan.licenseWarning.body', {
|
||||
total: formattedUserCount,
|
||||
limit: licenseAlert.freeTierLimit,
|
||||
})}
|
||||
buttonText={t('plan.licenseWarning.cta', 'See plans')}
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={scrollToPlans}
|
||||
dismissible={false}
|
||||
minHeight={68}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
)}
|
||||
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
|
||||
{licenseInfo?.licenseKey && isSupabaseConfigured && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
|
||||
</Text>
|
||||
<ManageBillingButton />
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentLicenseInfo={licenseInfo}
|
||||
|
||||
+10
-7
@@ -140,12 +140,15 @@ const AdminUsageSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
|
||||
const endpoints = data?.endpoints ?? [];
|
||||
const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
||||
|
||||
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
|
||||
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||
const totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
|
||||
const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
|
||||
|
||||
const displayedPercentage = (data?.totalVisits || 0) > 0
|
||||
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
|
||||
const displayedPercentage = totalVisits > 0
|
||||
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
@@ -220,7 +223,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalEndpoints}
|
||||
{totalEndpoints}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
@@ -228,7 +231,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
{t('usage.stats.totalVisits', 'Total Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalVisits.toLocaleString()}
|
||||
{totalVisits.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
@@ -253,7 +256,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
|
||||
{/* Chart and Table */}
|
||||
<UsageAnalyticsChart data={chartData} />
|
||||
<UsageAnalyticsTable data={data.endpoints} />
|
||||
<UsageAnalyticsTable data={endpoints} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
|
||||
@@ -34,6 +35,7 @@ export default function PeopleSection() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const navigate = useNavigate();
|
||||
const { licenseInfo: globalLicenseInfo } = useLicense();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
@@ -53,6 +55,24 @@ export default function PeopleSection() {
|
||||
premiumEnabled: boolean;
|
||||
totalUsers: number;
|
||||
} | null>(null);
|
||||
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
|
||||
const handleAddMembersClick = () => {
|
||||
if (!loginEnabled) {
|
||||
return;
|
||||
}
|
||||
if (hasNoSlots) {
|
||||
navigate('/settings/adminPlan');
|
||||
return;
|
||||
}
|
||||
setInviteModalOpened(true);
|
||||
};
|
||||
|
||||
const addMemberTooltip = !loginEnabled
|
||||
? t('workspace.people.loginRequired', 'Enable login mode first')
|
||||
: hasNoSlots
|
||||
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
|
||||
: null;
|
||||
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
@@ -326,9 +346,18 @@ export default function PeopleSection() {
|
||||
</Text>
|
||||
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
</Badge>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/adminPlan')}
|
||||
>
|
||||
{t('workspace.people.actions.upgrade', 'Upgrade')}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{licenseInfo.grandfatheredUserCount > 0 && (
|
||||
@@ -369,14 +398,14 @@ export default function PeopleSection() {
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
<Tooltip
|
||||
label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
label={addMemberTooltip || undefined}
|
||||
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
|
||||
position="bottom"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setInviteModalOpened(true)}
|
||||
onClick={handleAddMembersClick}
|
||||
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
|
||||
>
|
||||
{t('workspace.people.addMembers')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
|
||||
import licenseService, { LicenseInfo } from '@app/services/licenseService';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
|
||||
|
||||
interface LicenseContextValue {
|
||||
licenseInfo: LicenseInfo | null;
|
||||
@@ -57,6 +58,14 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
|
||||
console.log('[LicenseContext] Fetching license info');
|
||||
|
||||
try {
|
||||
const testInfo = getSimulatedLicenseInfo();
|
||||
if (testInfo) {
|
||||
setLicenseInfo(testInfo);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const info = await licenseService.getLicenseInfo();
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import {
|
||||
getSimulatedAdminUsage,
|
||||
getSimulatedWauResponse,
|
||||
} from '@app/testing/serverExperienceSimulations';
|
||||
|
||||
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
|
||||
const FREE_TIER_LIMIT = 5;
|
||||
|
||||
type UserCountSource = 'admin' | 'estimate' | 'unknown';
|
||||
|
||||
interface WeeklyActiveUsersResponse {
|
||||
trackingSince: string;
|
||||
daysOnline: number;
|
||||
totalUniqueBrowsers: number;
|
||||
weeklyActiveUsers: number;
|
||||
}
|
||||
|
||||
interface UserCountState {
|
||||
totalUsers: number | null;
|
||||
weeklyActiveUsers: number | null;
|
||||
loading: boolean;
|
||||
source: UserCountSource;
|
||||
lastUpdated: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type ServerScenarioKey =
|
||||
| 'unknown'
|
||||
| 'licensed'
|
||||
| 'no-login-user-under-limit-no-license'
|
||||
| 'no-login-admin-under-limit-no-license'
|
||||
| 'no-login-user-over-limit-no-license'
|
||||
| 'no-login-admin-over-limit-no-license'
|
||||
| 'login-user-under-limit-no-license'
|
||||
| 'login-admin-under-limit-no-license'
|
||||
| 'login-user-over-limit-no-license'
|
||||
| 'login-admin-over-limit-no-license';
|
||||
|
||||
export interface ServerExperienceValue {
|
||||
loginEnabled: boolean;
|
||||
configIsAdmin: boolean;
|
||||
effectiveIsAdmin: boolean;
|
||||
selfReportedAdmin: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isNewServer: boolean | null;
|
||||
isNewUser: boolean | null;
|
||||
premiumEnabled: boolean | null;
|
||||
license: string | undefined;
|
||||
runningProOrHigher: boolean | undefined;
|
||||
runningEE: boolean | undefined;
|
||||
hasPaidLicense: boolean;
|
||||
licenseKeyValid: boolean | null;
|
||||
licenseLoading: boolean;
|
||||
licenseInfoAvailable: boolean;
|
||||
totalUsers: number | null;
|
||||
weeklyActiveUsers: number | null;
|
||||
userCountLoading: boolean;
|
||||
userCountError: string | null;
|
||||
userCountSource: UserCountSource;
|
||||
userCountResolved: boolean;
|
||||
overFreeTierLimit: boolean | null;
|
||||
freeTierLimit: number;
|
||||
refreshUserCounts: () => Promise<void>;
|
||||
setSelfReportedAdmin: (value: boolean) => void;
|
||||
scenarioKey: ServerScenarioKey;
|
||||
}
|
||||
|
||||
const ServerExperienceContext = createContext<ServerExperienceValue | undefined>(undefined);
|
||||
|
||||
function getStoredSelfReportedAdmin(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'response' in error &&
|
||||
typeof (error as any).response?.data?.message === 'string'
|
||||
) {
|
||||
return (error as any).response.data.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unable to load server usage';
|
||||
}
|
||||
|
||||
export function ServerExperienceProvider({ children }: { children: ReactNode }) {
|
||||
const { config } = useAppConfig();
|
||||
const { user } = useAuth();
|
||||
const { licenseInfo, loading: licenseLoading } = useLicense();
|
||||
|
||||
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(getStoredSelfReportedAdmin);
|
||||
const [userCountState, setUserCountState] = useState<UserCountState>({
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: 'unknown',
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
const configIsAdmin = Boolean(config?.isAdmin);
|
||||
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
const isAuthenticated = Boolean(user);
|
||||
|
||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||
setSelfReportedAdminState(value);
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === SELF_REPORTED_ADMIN_KEY) {
|
||||
setSelfReportedAdminState(event.newValue === 'true');
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
|
||||
setSelfReportedAdmin(true);
|
||||
}
|
||||
}, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
|
||||
|
||||
const fetchUserCounts = useCallback(async () => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
|
||||
const shouldUseEstimate = config.enableLogin === false;
|
||||
|
||||
if (!shouldUseAdminData && !shouldUseEstimate) {
|
||||
setUserCountState((prev) => ({
|
||||
...prev,
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: 'unknown',
|
||||
error: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setUserCountState((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
if (shouldUseAdminData) {
|
||||
const testResponse = getSimulatedAdminUsage();
|
||||
const responseData =
|
||||
testResponse ??
|
||||
(
|
||||
await apiClient.get<{ totalUsers?: number }>(
|
||||
'/api/v1/proprietary/ui-data/admin-settings',
|
||||
{ suppressErrorToast: true } as any,
|
||||
)
|
||||
).data;
|
||||
const totalUsers =
|
||||
typeof responseData?.totalUsers === 'number' ? responseData.totalUsers : null;
|
||||
setUserCountState({
|
||||
totalUsers,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: 'admin',
|
||||
lastUpdated: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseEstimate) {
|
||||
const testResponse = getSimulatedWauResponse();
|
||||
const responseData =
|
||||
testResponse ??
|
||||
(
|
||||
await apiClient.get<WeeklyActiveUsersResponse>('/api/v1/info/wau', {
|
||||
suppressErrorToast: true,
|
||||
} as any)
|
||||
).data;
|
||||
const weeklyActiveUsers =
|
||||
typeof responseData?.weeklyActiveUsers === 'number'
|
||||
? responseData.weeklyActiveUsers
|
||||
: null;
|
||||
setUserCountState({
|
||||
totalUsers: weeklyActiveUsers,
|
||||
weeklyActiveUsers,
|
||||
loading: false,
|
||||
source: 'estimate',
|
||||
lastUpdated: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setUserCountState({
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: 'unknown',
|
||||
lastUpdated: null,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUserCounts();
|
||||
}, [fetchUserCounts]);
|
||||
|
||||
const refreshUserCounts = useCallback(async () => {
|
||||
await fetchUserCounts();
|
||||
}, [fetchUserCounts]);
|
||||
|
||||
const hasPaidLicense = useMemo(() => {
|
||||
return config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
}, [config?.license]);
|
||||
|
||||
const licenseKeyValid = useMemo(() => {
|
||||
if (licenseInfo) {
|
||||
return licenseInfo.hasKey && licenseInfo.enabled;
|
||||
}
|
||||
if (config?.premiumEnabled) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}, [config?.premiumEnabled, licenseInfo]);
|
||||
|
||||
const overFreeTierLimit = useMemo(() => {
|
||||
if (typeof userCountState.totalUsers !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return userCountState.totalUsers > FREE_TIER_LIMIT;
|
||||
}, [userCountState.totalUsers]);
|
||||
|
||||
const userCountResolved =
|
||||
!userCountState.loading && userCountState.source !== 'unknown' && userCountState.totalUsers !== null;
|
||||
|
||||
const scenarioKey = useMemo<ServerScenarioKey>(() => {
|
||||
if (hasPaidLicense) {
|
||||
return 'licensed';
|
||||
}
|
||||
if (!userCountResolved || typeof userCountState.totalUsers !== 'number') {
|
||||
return 'unknown';
|
||||
}
|
||||
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
|
||||
|
||||
if (!loginEnabled) {
|
||||
if (selfReportedAdmin) {
|
||||
return overLimit
|
||||
? 'no-login-admin-over-limit-no-license'
|
||||
: 'no-login-admin-under-limit-no-license';
|
||||
}
|
||||
return overLimit
|
||||
? 'no-login-user-over-limit-no-license'
|
||||
: 'no-login-user-under-limit-no-license';
|
||||
}
|
||||
|
||||
if (configIsAdmin) {
|
||||
return overLimit
|
||||
? 'login-admin-over-limit-no-license'
|
||||
: 'login-admin-under-limit-no-license';
|
||||
}
|
||||
|
||||
return overLimit
|
||||
? 'login-user-over-limit-no-license'
|
||||
: 'login-user-under-limit-no-license';
|
||||
}, [
|
||||
hasPaidLicense,
|
||||
userCountResolved,
|
||||
userCountState.totalUsers,
|
||||
loginEnabled,
|
||||
selfReportedAdmin,
|
||||
configIsAdmin,
|
||||
]);
|
||||
|
||||
const value: ServerExperienceValue = {
|
||||
loginEnabled,
|
||||
configIsAdmin,
|
||||
effectiveIsAdmin,
|
||||
selfReportedAdmin,
|
||||
isAuthenticated,
|
||||
isNewServer: config?.isNewServer ?? null,
|
||||
isNewUser: config?.isNewUser ?? null,
|
||||
premiumEnabled: config?.premiumEnabled ?? null,
|
||||
license: config?.license,
|
||||
runningProOrHigher: config?.runningProOrHigher,
|
||||
runningEE: config?.runningEE,
|
||||
hasPaidLicense,
|
||||
licenseKeyValid,
|
||||
licenseLoading,
|
||||
licenseInfoAvailable: Boolean(licenseInfo),
|
||||
totalUsers: userCountState.totalUsers,
|
||||
weeklyActiveUsers: userCountState.weeklyActiveUsers,
|
||||
userCountLoading: userCountState.loading,
|
||||
userCountError: userCountState.error,
|
||||
userCountSource: userCountState.source,
|
||||
userCountResolved,
|
||||
overFreeTierLimit,
|
||||
freeTierLimit: FREE_TIER_LIMIT,
|
||||
refreshUserCounts,
|
||||
setSelfReportedAdmin,
|
||||
scenarioKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerExperienceContext.Provider value={value}>
|
||||
{children}
|
||||
</ServerExperienceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerExperienceContext() {
|
||||
const context = useContext(ServerExperienceContext);
|
||||
if (!context) {
|
||||
throw new Error('useServerExperience must be used within ServerExperienceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useServerExperienceContext } from '@app/contexts/ServerExperienceContext';
|
||||
|
||||
export function useServerExperience() {
|
||||
return useServerExperienceContext();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { LicenseInfo } from '@app/services/licenseService';
|
||||
|
||||
interface WauResponse {
|
||||
trackingSince: string;
|
||||
daysOnline: number;
|
||||
totalUniqueBrowsers: number;
|
||||
weeklyActiveUsers: number;
|
||||
}
|
||||
|
||||
interface AdminUsageResponse {
|
||||
totalUsers?: number;
|
||||
}
|
||||
|
||||
interface SimulationScenario {
|
||||
/**
|
||||
* Human-friendly label describing the scenario.
|
||||
* Keep in sync with the comment map below.
|
||||
*/
|
||||
label: string;
|
||||
appConfig: AppConfig;
|
||||
wau?: WauResponse;
|
||||
adminUsage?: AdminUsageResponse;
|
||||
licenseInfo: LicenseInfo;
|
||||
}
|
||||
|
||||
const DEV_TESTING_MODE = false;
|
||||
|
||||
/**
|
||||
* Scenario index cheat sheet:
|
||||
* 0 → no-login-user-under-limit (no license)
|
||||
* 1 → no-login-admin-under-limit (no license)
|
||||
* 2 → no-login-user-over-limit (no license)
|
||||
* 3 → no-login-admin-over-limit (no license)
|
||||
* 4 → login-user-under-limit (no license)
|
||||
* 5 → login-admin-under-limit (no license)
|
||||
* 6 → login-user-over-limit (no license)
|
||||
* 7 → login-admin-over-limit (no license)
|
||||
*/
|
||||
const SIMULATION_INDEX = 0;
|
||||
|
||||
const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
licenseType: 'NORMAL',
|
||||
enabled: false,
|
||||
maxUsers: 5,
|
||||
hasKey: false,
|
||||
};
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.0.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
premiumEnabled: false,
|
||||
runningProOrHigher: false,
|
||||
runningEE: false,
|
||||
enableLogin: false,
|
||||
activeSecurity: false,
|
||||
languages: [],
|
||||
contextPath: '/',
|
||||
license: 'NORMAL',
|
||||
baseUrl: 'http://localhost',
|
||||
enableEmailInvites: true,
|
||||
};
|
||||
|
||||
const BASE_LOGIN_CONFIG: AppConfig = {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
enableLogin: true,
|
||||
activeSecurity: true,
|
||||
};
|
||||
|
||||
const SIMULATION_SCENARIOS: SimulationScenario[] = [
|
||||
{
|
||||
label: 'no-login-user-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-11-18T23:20:12.520884200Z',
|
||||
daysOnline: 0,
|
||||
totalUniqueBrowsers: 3,
|
||||
weeklyActiveUsers: 3,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-admin-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-10-01T00:00:00Z',
|
||||
daysOnline: 14,
|
||||
totalUniqueBrowsers: 4,
|
||||
weeklyActiveUsers: 4,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-user-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-09-01T00:00:00Z',
|
||||
daysOnline: 30,
|
||||
totalUniqueBrowsers: 12,
|
||||
weeklyActiveUsers: 9,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'no-login-admin-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_NO_LOGIN_CONFIG,
|
||||
},
|
||||
wau: {
|
||||
trackingSince: '2025-08-15T00:00:00Z',
|
||||
daysOnline: 45,
|
||||
totalUniqueBrowsers: 18,
|
||||
weeklyActiveUsers: 12,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-user-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: false,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 3,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-admin-under-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: true,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 4,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-user-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: false,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 12,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
{
|
||||
label: 'login-admin-over-limit (no-license)',
|
||||
appConfig: {
|
||||
...BASE_LOGIN_CONFIG,
|
||||
isAdmin: true,
|
||||
},
|
||||
adminUsage: {
|
||||
totalUsers: 57,
|
||||
},
|
||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||
},
|
||||
];
|
||||
|
||||
function getActiveScenario(): SimulationScenario | null {
|
||||
if (!DEV_TESTING_MODE) {
|
||||
return null;
|
||||
}
|
||||
const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
|
||||
if (!scenario) {
|
||||
console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
|
||||
return null;
|
||||
}
|
||||
console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
export function getSimulatedAppConfig(): AppConfig | null {
|
||||
return getActiveScenario()?.appConfig ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedWauResponse(): WauResponse | null {
|
||||
return getActiveScenario()?.wau ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedAdminUsage(): AdminUsageResponse | null {
|
||||
return getActiveScenario()?.adminUsage ?? null;
|
||||
}
|
||||
|
||||
export function getSimulatedLicenseInfo(): LicenseInfo | null {
|
||||
return getActiveScenario()?.licenseInfo ?? null;
|
||||
}
|
||||
|
||||
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
|
||||
|
||||
Reference in New Issue
Block a user