diff --git a/frontend/src/core/styles/tailwind.css b/frontend/src/core/styles/tailwind.css
index 9415e80cee..1408a13968 100644
--- a/frontend/src/core/styles/tailwind.css
+++ b/frontend/src/core/styles/tailwind.css
@@ -13,3 +13,13 @@
@layer utilities {
@tailwind utilities;
}
+
+@layer utilities {
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+ .no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+}
diff --git a/frontend/src/core/tsconfig.json b/frontend/src/core/tsconfig.json
new file mode 100644
index 0000000000..c32398094b
--- /dev/null
+++ b/frontend/src/core/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "../../",
+ "paths": {
+ "@app/*": [
+ "src/core/*"
+ ]
+ }
+ },
+ "include": [
+ "../global.d.ts",
+ "../*.js",
+ "../*.ts",
+ "../*.tsx",
+ "."
+ ]
+}
diff --git a/frontend/src/desktop/LICENSE b/frontend/src/desktop/LICENSE
new file mode 100644
index 0000000000..d268556808
--- /dev/null
+++ b/frontend/src/desktop/LICENSE
@@ -0,0 +1,51 @@
+Stirling PDF User License
+
+Copyright (c) 2025 Stirling PDF Inc.
+
+License Scope & Usage Rights
+
+Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
+
+For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
+provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
+processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
+(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
+covering the appropriate number of licensed users.
+
+Trial and Minimal Use
+
+You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
+* Use is limited to the capabilities and restrictions defined by the Software itself;
+* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
+
+Continued use beyond this scope requires a valid Stirling PDF User License.
+
+Modifications and Derivative Works
+
+You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
+
+* May not be deployed in production environments without a valid User License;
+* May not be distributed or sublicensed;
+* Remain the intellectual property of Stirling PDF and/or its licensors;
+* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
+
+Prohibited Actions
+
+Unless explicitly permitted by a paid license or separate agreement, you may not:
+
+* Use the Software in production environments;
+* Copy, merge, distribute, sublicense, or sell the Software;
+* Remove or alter any licensing or copyright notices;
+* Circumvent access restrictions or licensing requirements.
+
+Third-Party Components
+
+The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
+their original license terms as provided by their respective owners.
+
+Disclaimer
+
+THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frontend/src/desktop/components/shared/config/configNavSections.tsx b/frontend/src/desktop/components/shared/config/configNavSections.tsx
index 52635ab3cb..148ed1961e 100644
--- a/frontend/src/desktop/components/shared/config/configNavSections.tsx
+++ b/frontend/src/desktop/components/shared/config/configNavSections.tsx
@@ -8,6 +8,8 @@ import { SaaSTeamsSection } from '@app/components/shared/config/configSections/S
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
+export type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
+
/**
* Hook version of desktop config nav sections with proper i18n support
*/
diff --git a/frontend/tsconfig.desktop.json b/frontend/src/desktop/tsconfig.json
similarity index 63%
rename from frontend/tsconfig.desktop.json
rename to frontend/src/desktop/tsconfig.json
index 1d952b528a..6c6989ebc3 100644
--- a/frontend/tsconfig.desktop.json
+++ b/frontend/src/desktop/tsconfig.json
@@ -1,6 +1,7 @@
{
- "extends": "./tsconfig.proprietary.json",
+ "extends": "../../tsconfig.json",
"compilerOptions": {
+ "baseUrl": "../../",
"paths": {
"@app/*": [
"src/desktop/*",
@@ -16,11 +17,11 @@
}
},
"include": [
- "src/global.d.ts",
- "src/*.js",
- "src/*.ts",
- "src/*.tsx",
- "src/core/setupTests.ts",
- "src/desktop"
+ "../global.d.ts",
+ "../*.js",
+ "../*.ts",
+ "../*.tsx",
+ "../core/setupTests.ts",
+ "."
]
}
diff --git a/frontend/src/proprietary/tsconfig.json b/frontend/src/proprietary/tsconfig.json
new file mode 100644
index 0000000000..3bba3e8bc2
--- /dev/null
+++ b/frontend/src/proprietary/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "../../",
+ "paths": {
+ "@app/*": [
+ "src/proprietary/*",
+ "src/core/*"
+ ],
+ "@core/*": [
+ "src/core/*"
+ ]
+ }
+ },
+ "include": [
+ "../global.d.ts",
+ "../*.js",
+ "../*.ts",
+ "../*.tsx",
+ "../core/setupTests.ts",
+ "."
+ ]
+}
diff --git a/frontend/src/saas/App.tsx b/frontend/src/saas/App.tsx
new file mode 100644
index 0000000000..4a8c5f9b17
--- /dev/null
+++ b/frontend/src/saas/App.tsx
@@ -0,0 +1,49 @@
+import { Suspense } from 'react';
+import { Routes, Route } from 'react-router-dom';
+import { AppProviders } from '@app/components/AppProviders';
+import { setBaseUrl } from '@app/constants/app';
+import type { AppConfig } from '@app/contexts/AppConfigContext';
+import { AppLayout } from '@app/components/AppLayout';
+import { LoadingFallback } from '@app/components/shared/LoadingFallback';
+import OnboardingTour from '@app/components/onboarding/OnboardingTour';
+import Landing from '@app/routes/Landing';
+import Login from '@app/routes/Login';
+import Signup from '@app/routes/Signup';
+import AuthCallback from '@app/routes/AuthCallback';
+import ResetPassword from '@app/routes/ResetPassword';
+import OnboardingBootstrap from '@app/components/OnboardingBootstrap';
+import TrialExpiredBootstrap from '@app/components/TrialExpiredBootstrap';
+
+// Import global styles
+import '@app/styles/tailwind.css';
+import '@app/styles/saas-theme.css';
+import '@app/styles/cookieconsent.css';
+import '@app/styles/index.css';
+
+// Import file ID debugging helpers (development only)
+import '@app/utils/fileIdSafety';
+
+function handleConfigLoaded(config: AppConfig) {
+ if (config.baseUrl) setBaseUrl(config.baseUrl);
+}
+
+export default function App() {
+ return (
+
}>
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/saas/LICENSE b/frontend/src/saas/LICENSE
new file mode 100644
index 0000000000..d268556808
--- /dev/null
+++ b/frontend/src/saas/LICENSE
@@ -0,0 +1,51 @@
+Stirling PDF User License
+
+Copyright (c) 2025 Stirling PDF Inc.
+
+License Scope & Usage Rights
+
+Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
+
+For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
+provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
+processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
+(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
+covering the appropriate number of licensed users.
+
+Trial and Minimal Use
+
+You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
+* Use is limited to the capabilities and restrictions defined by the Software itself;
+* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
+
+Continued use beyond this scope requires a valid Stirling PDF User License.
+
+Modifications and Derivative Works
+
+You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
+
+* May not be deployed in production environments without a valid User License;
+* May not be distributed or sublicensed;
+* Remain the intellectual property of Stirling PDF and/or its licensors;
+* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
+
+Prohibited Actions
+
+Unless explicitly permitted by a paid license or separate agreement, you may not:
+
+* Use the Software in production environments;
+* Copy, merge, distribute, sublicense, or sell the Software;
+* Remove or alter any licensing or copyright notices;
+* Circumvent access restrictions or licensing requirements.
+
+Third-Party Components
+
+The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
+their original license terms as provided by their respective owners.
+
+Disclaimer
+
+THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frontend/src/saas/auth/UseSession.tsx b/frontend/src/saas/auth/UseSession.tsx
new file mode 100644
index 0000000000..0de2231676
--- /dev/null
+++ b/frontend/src/saas/auth/UseSession.tsx
@@ -0,0 +1,583 @@
+import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
+import { supabase } from '@app/auth/supabase'
+import type { Session, User as SupabaseUser, AuthError } from '@supabase/supabase-js'
+import { CreditSummary, SubscriptionInfo, CreditCheckResult, ApiCredits } from '@app/types/credits'
+import apiClient, { setGlobalCreditUpdateCallback } from '@app/services/apiClient'
+import { synchronizeUserUpgrade } from '@app/services/userService'
+import { syncOAuthAvatar, getProfilePictureMetadata, type ProfilePictureMetadata } from '@app/services/avatarSyncService'
+
+// Extend Supabase User to include optional username for compatibility
+export type User = SupabaseUser & { username?: string };
+
+export interface TrialStatus {
+ isTrialing: boolean
+ trialEnd: string
+ daysRemaining: number
+ hasPaymentMethod: boolean
+ hasScheduledSub: boolean
+ status: string
+}
+
+interface AuthContextType {
+ session: Session | null
+ user: User | null
+ loading: boolean
+ error: AuthError | null
+ creditBalance: number | null
+ subscription: SubscriptionInfo | null
+ creditSummary: CreditSummary | null
+ isPro: boolean | null
+ trialStatus: TrialStatus | null
+ profilePictureUrl: string | null
+ profilePictureMetadata: ProfilePictureMetadata | null
+ signOut: () => Promise
+ refreshSession: () => Promise
+ hasSufficientCredits: (requiredCredits: number) => CreditCheckResult
+ updateCredits: (newBalance: number) => void
+ refreshCredits: () => Promise
+ refreshProStatus: () => Promise
+ refreshTrialStatus: () => Promise
+ refreshProfilePicture: () => Promise
+ refreshProfilePictureMetadata: () => Promise
+}
+
+const AuthContext = createContext({
+ session: null,
+ user: null,
+ loading: true,
+ error: null,
+ creditBalance: null,
+ subscription: null,
+ creditSummary: null,
+ isPro: null,
+ trialStatus: null,
+ profilePictureUrl: null,
+ profilePictureMetadata: null,
+ signOut: async () => {},
+ refreshSession: async () => {},
+ hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }),
+ updateCredits: () => {},
+ refreshCredits: async () => {},
+ refreshProStatus: async () => {},
+ refreshTrialStatus: async () => {},
+ refreshProfilePicture: async () => {},
+ refreshProfilePictureMetadata: async () => {}
+})
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [session, setSession] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [creditBalance, setCreditBalance] = useState(null)
+ const [subscription, setSubscription] = useState(null)
+ const [creditSummary, setCreditSummary] = useState(null)
+ const [isPro, setIsPro] = useState(null)
+ const [trialStatus, setTrialStatus] = useState(null)
+ const [profilePictureUrl, setProfilePictureUrl] = useState(null)
+ const [profilePictureMetadata, setProfilePictureMetadata] = useState(null)
+
+ const fetchCredits = useCallback(async (sessionToUse?: Session | null) => {
+ const currentSession = sessionToUse ?? session
+
+ if (!currentSession?.user) {
+ console.debug('[Auth Debug] No user session, skipping credit fetch')
+ setCreditBalance(null)
+ setCreditSummary(null)
+ setSubscription(null)
+ return
+ }
+
+ try {
+ console.debug('[Auth Debug] Fetching credits for user:', currentSession.user.id)
+ const response = await apiClient.get('/api/v1/credits')
+ const apiCredits = response.data
+
+ // Map server payload to app CreditSummary
+ const credits: CreditSummary = {
+ currentCredits: apiCredits.totalAvailableCredits,
+ maxCredits: apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
+ creditsUsed: (apiCredits.weeklyCreditsAllocated - apiCredits.weeklyCreditsRemaining) + (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
+ creditsRemaining: apiCredits.totalAvailableCredits,
+ resetDate: apiCredits.weeklyResetDate,
+ weeklyAllowance: apiCredits.weeklyCreditsAllocated
+ }
+
+ setCreditSummary(credits)
+ setCreditBalance(credits.creditsRemaining)
+
+ const subscriptionInfo: SubscriptionInfo = {
+ status: 'active',
+ tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free',
+ creditsPerWeek: credits.weeklyAllowance,
+ maxCredits: credits.maxCredits
+ }
+ setSubscription(subscriptionInfo)
+
+ console.debug('[Auth Debug] Credits fetched successfully:', credits)
+ } catch (error: any) {
+ console.debug('[Auth Debug] Failed to fetch credits:', error)
+ // Don't set error state for credit fetching failures to avoid disrupting auth flow
+ // Credits might not be available in all deployments
+ setCreditBalance(null)
+ setCreditSummary(null)
+ setSubscription(null)
+ }
+ }, [session])
+
+ const refreshCredits = useCallback(async () => {
+ await fetchCredits()
+ }, [fetchCredits])
+
+ const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => {
+ const currentSession = sessionToUse ?? session
+
+ if (!currentSession?.user) {
+ console.debug('[Auth Debug] No user session, skipping pro status fetch')
+ setIsPro(null)
+ return
+ }
+
+ try {
+ console.debug('[Auth Debug] Fetching pro status for user:', currentSession.user.id)
+ const { data: proStatus, error } = await supabase.rpc('is_pro')
+
+ if (error) {
+ console.error('[Auth Debug] Error checking Pro status:', error)
+ setIsPro(false) // Default to false if there's an error
+ } else {
+ const isProUser = Boolean(proStatus)
+ setIsPro(isProUser)
+ console.debug('[Auth Debug] Pro status fetched:', isProUser)
+ }
+ } catch (error: any) {
+ console.debug('[Auth Debug] Failed to fetch pro status:', error)
+ setIsPro(false) // Default to false if there's an error
+ }
+ }, [session])
+
+ const refreshProStatus = useCallback(async () => {
+ await fetchProStatus()
+ }, [fetchProStatus])
+
+ const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => {
+ const currentSession = sessionToUse ?? session
+
+ if (!currentSession?.user) {
+ console.debug('[Auth Debug] No user session, skipping trial status fetch')
+ setTrialStatus(null)
+ return
+ }
+
+ try {
+ console.debug('[Auth Debug] Fetching trial status for user:', currentSession.user.id)
+ const { data, error } = await supabase
+ .from('billing_subscriptions')
+ .select('status, trial_end, has_payment_method, scheduled_subscription_id')
+ .in('status', ['trialing', 'incomplete_expired', 'canceled'])
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle()
+
+ if (error) {
+ console.error('[Auth Debug] Error fetching trial status:', error)
+ setTrialStatus(null)
+ return
+ }
+
+ if (data?.trial_end) {
+ const trialEnd = new Date(data.trial_end)
+ const now = new Date()
+ const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
+
+ setTrialStatus({
+ isTrialing: data.status === 'trialing' && daysRemaining > 0,
+ trialEnd: data.trial_end,
+ daysRemaining: Math.max(0, daysRemaining),
+ hasPaymentMethod: data.has_payment_method || false,
+ hasScheduledSub: !!data.scheduled_subscription_id,
+ status: data.status
+ })
+ console.debug('[Auth Debug] Trial status fetched:', {
+ status: data.status,
+ daysRemaining: Math.max(0, daysRemaining),
+ hasPaymentMethod: data.has_payment_method,
+ isTrialing: data.status === 'trialing' && daysRemaining > 0
+ })
+ } else {
+ setTrialStatus(null)
+ }
+ } catch (error: any) {
+ console.debug('[Auth Debug] Failed to fetch trial status:', error)
+ setTrialStatus(null)
+ }
+ }, [session])
+
+ const refreshTrialStatus = useCallback(async () => {
+ await fetchTrialStatus()
+ }, [fetchTrialStatus])
+
+ const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => {
+ const currentSession = sessionToUse ?? session
+
+ if (!currentSession?.user) {
+ console.debug('[Auth Debug] No user session, skipping profile picture fetch')
+ setProfilePictureUrl(null)
+ return
+ }
+
+ try {
+ const PROFILE_BUCKET = 'profile-pictures'
+ const profilePath = `${currentSession.user.id}/avatar`
+
+ console.debug('[Auth Debug] Fetching profile picture for user:', currentSession.user.id)
+ const { data, error } = await supabase
+ .storage
+ .from(PROFILE_BUCKET)
+ .createSignedUrl(profilePath, 60 * 60)
+
+ if (error) {
+ // Profile picture not found is expected for users without uploads
+ console.debug('[Auth Debug] Profile picture not available:', error.message)
+ setProfilePictureUrl(null)
+ } else {
+ setProfilePictureUrl(data.signedUrl)
+ console.debug('[Auth Debug] Profile picture URL fetched successfully')
+ }
+ } catch (error: any) {
+ console.debug('[Auth Debug] Failed to fetch profile picture:', error)
+ setProfilePictureUrl(null)
+ }
+ }, [session])
+
+ const refreshProfilePicture = useCallback(async () => {
+ await fetchProfilePicture()
+ }, [fetchProfilePicture])
+
+ const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => {
+ const currentSession = sessionToUse ?? session
+
+ if (!currentSession?.user) {
+ console.debug('[Auth Debug] No user session, skipping profile picture metadata fetch')
+ setProfilePictureMetadata(null)
+ return
+ }
+
+ try {
+ console.debug('[Auth Debug] Fetching profile picture metadata for user:', currentSession.user.id)
+ const metadata = await getProfilePictureMetadata(currentSession.user.id)
+ setProfilePictureMetadata(metadata)
+ console.debug('[Auth Debug] Profile picture metadata fetched:', metadata)
+ } catch (error: any) {
+ console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
+ setProfilePictureMetadata(null)
+ }
+ }, [session])
+
+ const refreshProfilePictureMetadata = useCallback(async () => {
+ await fetchProfilePictureMetadata()
+ }, [fetchProfilePictureMetadata])
+
+ const updateCredits = useCallback((newBalance: number) => {
+ console.debug('[Auth Debug] Updating credit balance:', { from: creditBalance, to: newBalance })
+ setCreditBalance(newBalance)
+ // Also update the creditSummary if it exists
+ if (creditSummary) {
+ const updatedSummary: CreditSummary = {
+ ...creditSummary,
+ creditsRemaining: newBalance,
+ currentCredits: newBalance
+ }
+ setCreditSummary(updatedSummary)
+ }
+ }, [creditSummary])
+
+ const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => {
+ const currentBalance = creditBalance ?? 0
+ const hasSufficient = currentBalance >= requiredCredits
+ console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient })
+
+ return {
+ hasSufficientCredits: hasSufficient,
+ currentBalance,
+ requiredCredits,
+ shortfall: hasSufficient ? undefined : requiredCredits - currentBalance
+ }
+ }, [creditBalance])
+
+ const refreshSession = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const { data, error } = await supabase.auth.refreshSession()
+
+ if (error) {
+ console.error('[Auth Debug] Session refresh error:', error)
+ setError(error)
+ setSession(null)
+ } else {
+ console.debug('[Auth Debug] Session refreshed successfully')
+ setSession(data.session)
+ }
+ } catch (err) {
+ console.error('[Auth Debug] Unexpected error during session refresh:', err)
+ setError(err as AuthError)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const signOut = async () => {
+ try {
+ setError(null)
+ const { error } = await supabase.auth.signOut()
+
+ if (error) {
+ console.error('[Auth Debug] Sign out error:', error)
+ setError(error)
+ } else {
+ console.debug('[Auth Debug] Signed out successfully')
+ setSession(null)
+ }
+ } catch (err) {
+ console.error('[Auth Debug] Unexpected error during sign out:', err)
+ setError(err as AuthError)
+ }
+ }
+
+ // Set up global credit update callback
+ useEffect(() => {
+ setGlobalCreditUpdateCallback(updateCredits)
+ }, [updateCredits])
+
+ useEffect(() => {
+ let mounted = true
+
+ // Load current session on first mount
+ const initializeAuth = async () => {
+ try {
+ console.debug('[Auth Debug] Initializing auth...')
+ const { data, error } = await supabase.auth.getSession()
+
+ if (!mounted) return
+
+ if (error) {
+ console.error('[Auth Debug] Initial session error:', error)
+ setError(error)
+ } else {
+ console.debug('[Auth Debug] Initial session loaded:', {
+ hasSession: !!data.session,
+ userId: data.session?.user?.id,
+ email: data.session?.user?.email
+ })
+ setSession(data.session)
+
+ // Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
+ if (data.session?.user) {
+ // Sync OAuth avatar in background
+ syncOAuthAvatar(data.session.user).catch((err) => {
+ console.debug('[Auth Debug] Failed to sync OAuth avatar on init:', err)
+ })
+
+ await fetchCredits(data.session)
+ await fetchProStatus(data.session)
+ await fetchTrialStatus(data.session)
+ await fetchProfilePictureMetadata(data.session)
+
+ // Small delay to allow avatar sync to complete if quick
+ setTimeout(() => {
+ fetchProfilePicture(data.session)
+ }, 500)
+ }
+ }
+ } catch (err) {
+ console.error('[Auth Debug] Unexpected error during auth initialization:', err)
+ if (mounted) {
+ setError(err as AuthError)
+ }
+ } finally {
+ if (mounted) {
+ setLoading(false)
+ }
+ }
+ }
+
+ initializeAuth()
+
+ // Subscribe to auth state changes
+ const { data: { subscription } } = supabase.auth.onAuthStateChange(
+ async (event, newSession) => {
+ if (!mounted) return
+
+ console.debug('[Auth Debug] Auth state change:', {
+ event,
+ hasSession: !!newSession,
+ userId: newSession?.user?.id,
+ email: newSession?.user?.email,
+ timestamp: new Date().toISOString()
+ })
+
+ // Don't run supabase calls inside this callback; schedule them
+ setTimeout(() => {
+ if (mounted) {
+ setSession(newSession)
+ setError(null)
+
+ // Additional handling for specific events
+ if (event === 'SIGNED_OUT') {
+ console.debug('[Auth Debug] User signed out, clearing session')
+ // Clear credit data, pro status, trial status, profile picture, and metadata on sign out
+ setCreditBalance(null)
+ setCreditSummary(null)
+ setSubscription(null)
+ setIsPro(null)
+ setTrialStatus(null)
+ setProfilePictureUrl(null)
+ setProfilePictureMetadata(null)
+ } else if (event === 'SIGNED_IN') {
+ console.debug('[Auth Debug] User signed in successfully')
+ if (newSession?.user) {
+ setLoading(true)
+
+ // Sync OAuth avatar in background (don't block other fetches)
+ syncOAuthAvatar(newSession.user).catch((err) => {
+ console.debug('[Auth Debug] Failed to sync OAuth avatar:', err)
+ })
+
+ // Fetch user data in parallel
+ Promise.all([
+ fetchCredits(newSession),
+ fetchProStatus(newSession),
+ fetchTrialStatus(newSession),
+ fetchProfilePictureMetadata(newSession),
+ ]).then(() => {
+ // Fetch profile picture AFTER sync has had time to complete
+ // Use a small delay to allow avatar sync to finish if it's quick
+ setTimeout(() => {
+ fetchProfilePicture(newSession).finally(() => {
+ setLoading(false)
+ console.debug('[Auth Debug] User data fully loaded after sign in')
+ })
+ }, 500)
+ })
+ }
+ } else if (event === 'TOKEN_REFRESHED') {
+ console.debug('[Auth Debug] Token refreshed')
+ // Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
+ if (newSession?.user) {
+ Promise.all([
+ fetchCredits(newSession),
+ fetchProStatus(newSession),
+ fetchTrialStatus(newSession),
+ fetchProfilePictureMetadata(newSession),
+ fetchProfilePicture(newSession)
+ ]).then(() => {
+ console.debug('[Auth Debug] User data refreshed after token refresh')
+ })
+ }
+ } else if (event === 'USER_UPDATED') {
+ console.debug('[Auth Debug] User updated')
+
+ // Check if this is a pending OAuth upgrade completion
+ const pendingUpgrade = sessionStorage.getItem('pendingUpgrade')
+ const upgradeProvider = sessionStorage.getItem('upgradeProvider')
+
+ if (pendingUpgrade && newSession?.user && newSession.user.is_anonymous === false) {
+ console.debug('[Auth Debug] Processing pending OAuth upgrade:', upgradeProvider)
+
+ // Clear the flags first to prevent loops
+ sessionStorage.removeItem('pendingUpgrade')
+ sessionStorage.removeItem('upgradeProvider')
+
+ // Synchronize with backend
+ synchronizeUserUpgrade(upgradeProvider || undefined)
+ .then(() => {
+ console.debug('[Auth Debug] User upgrade synchronized successfully')
+
+ // Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
+ if (newSession?.user) {
+ return Promise.all([
+ fetchCredits(newSession),
+ fetchProStatus(newSession),
+ fetchTrialStatus(newSession),
+ fetchProfilePictureMetadata(newSession),
+ fetchProfilePicture(newSession)
+ ])
+ }
+ })
+ .then(() => {
+ console.debug('[Auth Debug] User data refreshed after upgrade')
+ })
+ .catch((err) => {
+ console.error('[Auth Debug] Failed to synchronize user upgrade:', err)
+ })
+ }
+ }
+ }
+ }, 0)
+ }
+ )
+
+ return () => {
+ mounted = false
+ subscription.unsubscribe()
+ }
+ }, [])
+
+ const value: AuthContextType = {
+ session,
+ user: session?.user ?? null,
+ loading,
+ error,
+ creditBalance,
+ subscription,
+ creditSummary,
+ isPro,
+ trialStatus,
+ profilePictureUrl,
+ profilePictureMetadata,
+ signOut,
+ refreshSession,
+ hasSufficientCredits,
+ updateCredits,
+ refreshCredits,
+ refreshProStatus,
+ refreshTrialStatus,
+ refreshProfilePicture,
+ refreshProfilePictureMetadata
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider')
+ }
+
+ return context
+}
+
+// Debug hook to expose auth state for debugging
+export function useAuthDebug() {
+ const auth = useAuth()
+
+ useEffect(() => {
+ console.debug('[Auth Debug] Current auth state:', {
+ hasSession: !!auth.session,
+ hasUser: !!auth.user,
+ loading: auth.loading,
+ hasError: !!auth.error,
+ userId: auth.user?.id,
+ email: auth.user?.email,
+ provider: auth.user?.app_metadata?.provider
+ })
+ }, [auth.session, auth.user, auth.loading, auth.error])
+
+ return auth
+}
diff --git a/frontend/src/saas/auth/supabase.ts b/frontend/src/saas/auth/supabase.ts
new file mode 100644
index 0000000000..b8b473f785
--- /dev/null
+++ b/frontend/src/saas/auth/supabase.ts
@@ -0,0 +1,165 @@
+import { createClient } from '@supabase/supabase-js'
+
+// Debug helper to log Supabase configuration
+const debugConfig = () => {
+ const url = import.meta.env.VITE_SUPABASE_URL
+ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY
+
+ console.log('[Supabase Debug] Configuration:', {
+ url: url ? '✓ URL configured' : '✗ URL missing',
+ key: key ? '✓ Key configured' : '✗ Key missing',
+ urlValue: url || 'undefined',
+ keyValue: key ? `${key.substring(0, 20)}...` : 'undefined'
+ })
+
+ return { url, key }
+}
+
+const config = debugConfig()
+
+if (!config.url) {
+ throw new Error('Missing VITE_SUPABASE_URL environment variable')
+}
+
+if (!config.key) {
+ throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable')
+}
+
+export const supabase = createClient(
+ config.url,
+ config.key,
+ {
+ auth: {
+ persistSession: true, // keep session in localStorage
+ autoRefreshToken: true,
+ detectSessionInUrl: true, // helpful on first load after redirect
+ // debug: import.meta.env.DEV, // Enable debug logs in development
+ },
+ }
+)
+
+// Debug helper for auth events
+export const debugAuthEvents = () => {
+ supabase.auth.onAuthStateChange((event, session) => {
+ console.log('[Supabase Debug] Auth state change:', {
+ event,
+ hasSession: !!session,
+ userId: session?.user?.id,
+ email: session?.user?.email,
+ provider: session?.user?.app_metadata?.provider,
+ timestamp: new Date().toISOString()
+ })
+ })
+}
+
+// Debug auth events can be manually enabled by calling debugAuthEvents()
+// Commented out to prevent excessive logging on every page load
+// if (import.meta.env.DEV) {
+// debugAuthEvents()
+// }
+
+// Anonymous authentication functions
+export const signInAnonymously = async () => {
+ try {
+ const { data, error } = await supabase.auth.signInAnonymously()
+
+ if (error) {
+ console.error('[Supabase] Anonymous sign-in error:', error)
+ throw error
+ }
+
+ console.log('[Supabase] Anonymous sign-in successful:', {
+ userId: data.user?.id,
+ isAnonymous: data.user?.is_anonymous
+ })
+
+ return { data, error }
+ } catch (error) {
+ console.error('[Supabase] Anonymous sign-in failed:', error)
+ throw error
+ }
+}
+
+// Account linking functions
+export const linkEmailIdentity = async (email: string, password?: string) => {
+ try {
+ const updateData: any = { email }
+ if (password) {
+ updateData.password = password
+ }
+
+ const { data, error } = await supabase.auth.updateUser(updateData)
+
+ if (error) {
+ console.error('[Supabase] Email linking error:', error)
+ throw error
+ }
+
+ // Refresh session to get updated token with new user metadata
+ const { error: refreshError } = await supabase.auth.refreshSession()
+
+ if (refreshError) {
+ console.warn('[Supabase] Session refresh after email linking failed:', refreshError)
+ // Don't throw - linking was successful, refresh is just for consistency
+ } else {
+ console.log('[Supabase] Session refreshed after email linking')
+ }
+
+ console.log('[Supabase] Email linked successfully:', {
+ email,
+ userId: data.user?.id
+ })
+
+ return { data, error }
+ } catch (error) {
+ console.error('[Supabase] Email linking failed:', error)
+ throw error
+ }
+}
+
+export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple' | 'azure', redirectTo?: string) => {
+ try {
+ const { data, error } = await supabase.auth.linkIdentity({
+ provider: provider,
+ options: redirectTo ? { redirectTo } : undefined
+ })
+
+ if (error) {
+ console.error('[Supabase] OAuth linking error:', error)
+ throw error
+ }
+
+ console.log('[Supabase] OAuth identity linked successfully:', {
+ provider,
+ redirectTo,
+ url: data.url
+ })
+
+ return { data, error }
+ } catch (error) {
+ console.error('[Supabase] OAuth linking failed:', error)
+ throw error
+ }
+}
+
+// Helper function to check if user is anonymous
+export const isUserAnonymous = (user: any) => {
+ return user?.is_anonymous === true
+}
+
+// Get current user session
+export const getCurrentUser = async () => {
+ try {
+ const { data: { user }, error } = await supabase.auth.getUser()
+
+ if (error) {
+ console.error('[Supabase] Get user error:', error)
+ throw error
+ }
+
+ return user
+ } catch (error) {
+ console.error('[Supabase] Get user failed:', error)
+ throw error
+ }
+}
diff --git a/frontend/src/saas/components/OnboardingBootstrap.tsx b/frontend/src/saas/components/OnboardingBootstrap.tsx
new file mode 100644
index 0000000000..3533f429c5
--- /dev/null
+++ b/frontend/src/saas/components/OnboardingBootstrap.tsx
@@ -0,0 +1,115 @@
+import { useEffect, useState } from 'react';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
+import { useAuth } from '@app/auth/UseSession';
+import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal';
+
+const STORAGE_KEY = 'saas_onboarding_seen';
+const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
+
+/**
+ * SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
+ * and show SaaS-specific onboarding on first login.
+ */
+export default function OnboardingBootstrap() {
+ const { preferences, updatePreference } = usePreferences();
+ const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
+ const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
+ const [showModal, setShowModal] = useState(false);
+ const [isPolling, setIsPolling] = useState(false);
+ const [pollAttempts, setPollAttempts] = useState(0);
+
+ // Start polling when user logs in
+ useEffect(() => {
+ const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
+
+ if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
+ console.debug('[Onboarding] Starting poll for trial data');
+ setIsPolling(true);
+ setPollAttempts(0);
+ }
+ }, [user, loading, isPolling, showModal]);
+
+ // Poll for trial data
+ useEffect(() => {
+ if (!isPolling) return;
+
+ const pollInterval = 500; // Check every 500ms
+
+ const timer = setTimeout(async () => {
+ const newAttempts = pollAttempts + 1;
+ console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
+
+ await refreshTrialStatus();
+ setPollAttempts(newAttempts);
+
+ // Check will happen in the next effect
+ }, pollInterval);
+
+ return () => clearTimeout(timer);
+ }, [isPolling, pollAttempts, refreshTrialStatus]);
+
+ // Stop polling when data arrives or timeout
+ useEffect(() => {
+ if (!isPolling) return;
+
+ const hasData = trialStatus !== undefined && trialStatus !== null;
+ const hasProStatus = isPro !== null;
+ const maxAttempts = 10;
+
+ if (hasData || pollAttempts >= maxAttempts) {
+ console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
+ hasData,
+ hasProStatus,
+ attempts: pollAttempts,
+ trialStatus,
+ isPro
+ });
+ setIsPolling(false);
+ setShowModal(true);
+ }
+ }, [isPolling, trialStatus, isPro, pollAttempts]);
+
+ const handleClose = () => {
+ localStorage.setItem(STORAGE_KEY, 'true');
+ setShowModal(false);
+ };
+
+ // Keep existing logic to disable core onboarding flags
+ useEffect(() => {
+ // Ensure tool panel preference is set so tours are never deferred.
+ if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
+ updatePreference('toolPanelModePromptSeen', true);
+ updatePreference('hasSelectedToolPanelMode', true);
+ }
+
+ // Clear any lingering deferred tour requests.
+ clearPendingTourRequest();
+ setStartAfterToolModeSelection(false);
+
+ // In SaaS, skip the core intro onboarding entirely.
+ if (!preferences.hasSeenIntroOnboarding) {
+ updatePreference('hasSeenIntroOnboarding', true);
+ }
+ // Also mark completed to avoid follow-up banners/modals.
+ if (!preferences.hasCompletedOnboarding) {
+ updatePreference('hasCompletedOnboarding', true);
+ }
+
+ // Also clear any session flag that might mark onboarding as active.
+ if (typeof window !== 'undefined') {
+ window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
+ }
+ }, [
+ preferences.hasSelectedToolPanelMode,
+ preferences.toolPanelModePromptSeen,
+ preferences.hasSeenIntroOnboarding,
+ preferences.hasCompletedOnboarding,
+ updatePreference,
+ clearPendingTourRequest,
+ setStartAfterToolModeSelection,
+ ]);
+
+ // Only render modal when it should be shown to avoid running hooks unnecessarily
+ return showModal ? : null;
+}
diff --git a/frontend/src/saas/components/TrialExpiredBootstrap.tsx b/frontend/src/saas/components/TrialExpiredBootstrap.tsx
new file mode 100644
index 0000000000..8ccd575852
--- /dev/null
+++ b/frontend/src/saas/components/TrialExpiredBootstrap.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from 'react';
+import { useAuth } from '@app/auth/UseSession';
+import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal';
+import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
+
+/**
+ * Bootstrap component that shows the trial expired modal when a user's trial has ended
+ * and they haven't added a payment method. Shows once per user per expired trial.
+ */
+export default function TrialExpiredBootstrap() {
+ const { user, trialStatus, isPro } = useAuth();
+ const [showModal, setShowModal] = useState(false);
+ const [checkoutOpened, setCheckoutOpened] = useState(false);
+
+ useEffect(() => {
+ // Close modal if user logs out or session expires
+ if (!user) {
+ if (showModal) {
+ console.debug('[TrialExpired] User logged out, closing modal');
+ setShowModal(false);
+ }
+ if (checkoutOpened) {
+ setCheckoutOpened(false);
+ }
+ return;
+ }
+
+ // Only check conditions when auth is fully loaded
+ if (trialStatus === null || isPro === null) {
+ return;
+ }
+
+ // Build localStorage key unique to this user
+ const storageKey = `trialExpiredModalShown_${user.id}`;
+ const hasSeenModal = localStorage.getItem(storageKey) === 'true';
+
+ // If user is currently trialing, clear any previous "seen" flag
+ // This handles the edge case where a user might re-enter a trial
+ if (trialStatus.isTrialing) {
+ if (hasSeenModal) {
+ console.debug('[TrialExpired] User is trialing, clearing seen flag');
+ localStorage.removeItem(storageKey);
+ }
+ return;
+ }
+
+ // Check if all conditions are met to show the modal
+ const isExpired =
+ trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled';
+ const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
+ const wasDowngraded = !isPro;
+ const trialEndedRecently = trialStatus.daysRemaining === 0;
+
+ const shouldShowModal =
+ isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
+
+ if (shouldShowModal) {
+ console.debug('[TrialExpired] Showing trial expired modal', {
+ status: trialStatus.status,
+ daysRemaining: trialStatus.daysRemaining,
+ hasPaymentMethod: trialStatus.hasPaymentMethod,
+ hasScheduledSub: trialStatus.hasScheduledSub,
+ isPro,
+ });
+ setShowModal(true);
+ }
+ }, [user, trialStatus, isPro, showModal, checkoutOpened]);
+
+ const handleClose = () => {
+ if (user) {
+ const storageKey = `trialExpiredModalShown_${user.id}`;
+ localStorage.setItem(storageKey, 'true');
+ console.debug('[TrialExpired] Modal dismissed, marking as seen');
+ }
+ setShowModal(false);
+ };
+
+ const handleSubscribe = () => {
+ console.debug('[TrialExpired] User clicked Subscribe to Pro');
+ setCheckoutOpened(true);
+ };
+
+ const handleCheckoutSuccess = () => {
+ console.debug('[TrialExpired] Subscription successful, refreshing page');
+ // Close modal and refresh to update subscription status
+ handleClose();
+ window.location.reload();
+ };
+
+ const handleCheckoutClose = () => {
+ console.debug('[TrialExpired] Checkout closed');
+ setCheckoutOpened(false);
+ };
+
+ return (
+ <>
+
+
+ {user && (
+ console.error('[TrialExpired] Checkout error:', error)}
+ isTrialConversion={false} // Trial already ended, so this is not a conversion
+ />
+ )}
+ >
+ );
+}
diff --git a/frontend/src/saas/components/auth/GuestUserBanner.css b/frontend/src/saas/components/auth/GuestUserBanner.css
new file mode 100644
index 0000000000..d1d6937024
--- /dev/null
+++ b/frontend/src/saas/components/auth/GuestUserBanner.css
@@ -0,0 +1,90 @@
+.guest-banner {
+ position: fixed;
+ top: 1rem;
+ right: 4.5rem;
+ z-index: 1000;
+ background: var(--modal-content-bg, #111418);
+ border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08));
+ border-radius: 12px;
+ box-shadow: 0 6px 24px rgba(0,0,0,0.35);
+ padding: 12px 16px;
+ max-width: 30rem;
+ width: 30rem;
+ color: var(--mantine-color-text);
+}
+
+.guest-banner-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.guest-banner-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.guest-banner-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+ margin-bottom: 4px;
+ line-height: 1.3;
+}
+
+.guest-banner-message {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.guest-banner-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.guest-banner-dismiss {
+ padding: 6px;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--mantine-color-dimmed);
+ border: none;
+ cursor: pointer;
+}
+
+.guest-banner-dismiss:hover {
+ background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
+}
+
+.guest-banner-signup {
+ white-space: nowrap;
+ padding: 6px 12px;
+ border-radius: 8px;
+ background: var(--blue-6, #2563eb);
+ color: #fff;
+ border: none;
+ font-size: 12px;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+}
+
+.guest-banner-signup:hover {
+ background: var(--blue-7, #1d4ed8);
+}
+
+.guest-banner-icon {
+ width: 16px;
+ height: 16px;
+}
+
+.guest-banner-signup-icon {
+ width: 14px;
+ height: 14px;
+}
diff --git a/frontend/src/saas/components/auth/GuestUserBanner.tsx b/frontend/src/saas/components/auth/GuestUserBanner.tsx
new file mode 100644
index 0000000000..8c5f786aa0
--- /dev/null
+++ b/frontend/src/saas/components/auth/GuestUserBanner.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import CloseIcon from '@mui/icons-material/Close'
+import PersonAddIcon from '@mui/icons-material/PersonAdd'
+import { useAuth } from '@app/auth/UseSession'
+import { isUserAnonymous } from '@app/auth/supabase'
+import { withBasePath } from '@app/constants/app'
+import '@app/components/auth/GuestUserBanner.css'
+
+interface GuestUserBannerProps {
+ className?: string
+}
+
+// Ensure the toast only appears once per full page load, not on re-hydration
+let hasShownThisLoad = false
+
+/**
+ * Guest user toast encouraging account creation.
+ * Appears 2s after load, top-right of the viewport, offset by right rail.
+ */
+export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
+ const { t } = useTranslation()
+ const { session } = useAuth()
+ const [isDismissed, setIsDismissed] = useState(false)
+ const [visible, setVisible] = useState(false)
+
+ const isAnon = Boolean(session?.user && isUserAnonymous(session.user))
+
+ useEffect(() => {
+ if (!isAnon || hasShownThisLoad) return
+
+ const timer = setTimeout(() => {
+ setVisible(true)
+ hasShownThisLoad = true
+ }, 2000)
+
+ return () => clearTimeout(timer)
+ }, [isAnon])
+
+ if (!isAnon || isDismissed || !visible) {
+ return null
+ }
+
+ const handleSignUp = () => {
+ window.location.href = withBasePath('/signup')
+ }
+
+ const handleDismiss = () => {
+ setIsDismissed(true)
+ }
+
+ return (
+
+
+
+
+ {t('guestBanner.title', "You're using Stirling PDF as a guest!")}
+
+
+ {t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')}
+
+
+
+
+
+
+
+
+ {t('guestBanner.signUp', 'Sign Up Free')}
+
+
+
+
+ )
+}
+
+export default GuestUserBanner
\ No newline at end of file
diff --git a/frontend/src/saas/components/auth/RequireAuth.tsx b/frontend/src/saas/components/auth/RequireAuth.tsx
new file mode 100644
index 0000000000..fb6b550ba2
--- /dev/null
+++ b/frontend/src/saas/components/auth/RequireAuth.tsx
@@ -0,0 +1,44 @@
+import { Navigate, Outlet, useLocation } from 'react-router-dom'
+import { useAuth } from '@app/auth/UseSession'
+import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
+
+interface RequireAuthProps {
+ fallbackPath?: string
+}
+
+export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
+ const { session, loading } = useAuth()
+ const location = useLocation()
+ const { isAutoAuthenticating } = useAutoAnonymousAuth()
+
+ // Safe development-only auth bypass
+ const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
+ const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
+
+ if (devBypassEnabled) {
+ console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost')
+ return
+ }
+
+ // Wait for both auth bootstrap and auto-anon to finish
+ if (loading || isAutoAuthenticating) {
+ return (
+
+
+
+
Preparing your session…
+
+
+ )
+ }
+
+ if (!session) {
+ // Change the URL to /login
+ return
+ }
+
+ // Render protected routes
+ return
+}
+
+export default RequireAuth
diff --git a/frontend/src/saas/components/feedback/UserbackWidget.tsx b/frontend/src/saas/components/feedback/UserbackWidget.tsx
new file mode 100644
index 0000000000..e664cce535
--- /dev/null
+++ b/frontend/src/saas/components/feedback/UserbackWidget.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useRef } from 'react';
+import Userback from '@userback/widget';
+import { useAuth } from '@app/auth/UseSession';
+
+interface UserbackWidgetProps {
+ token: string;
+}
+
+interface UserbackInstance {
+ destroy: () => void;
+}
+
+export default function UserbackWidget({ token }: UserbackWidgetProps) {
+ const { user } = useAuth();
+ const userbackRef = useRef(null);
+ const initializingRef = useRef(false);
+
+ useEffect(() => {
+ if (!user || initializingRef.current) return;
+
+ initializingRef.current = true;
+
+ const initializeUserback = async () => {
+ try {
+ // Prepare user data options
+ const userInfo: { name?: string; email?: string } = {};
+ if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
+ if (user.email) userInfo.email = user.email;
+
+ const options = {
+ user_data: {
+ id: user.id,
+ info: userInfo
+ }
+ };
+
+ // Initialize Userback
+ userbackRef.current = await Userback(token, options);
+ }
+ finally {
+ initializingRef.current = false;
+ }
+ };
+
+ initializeUserback();
+
+ // Cleanup function
+ return () => {
+ if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
+ userbackRef.current.destroy();
+ }
+ initializingRef.current = false;
+ };
+ }, [user, token]);
+
+ return null; // This component doesn't render anything visible
+}
diff --git a/frontend/src/saas/components/home/HomePageExtensions.tsx b/frontend/src/saas/components/home/HomePageExtensions.tsx
new file mode 100644
index 0000000000..3557c976c1
--- /dev/null
+++ b/frontend/src/saas/components/home/HomePageExtensions.tsx
@@ -0,0 +1,6 @@
+import UserbackWidget from '@app/components/feedback/UserbackWidget';
+
+export function HomePageExtensions() {
+ const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
+ return userbackToken ? : null;
+}
diff --git a/frontend/src/saas/components/onboarding/OnboardingTour.tsx b/frontend/src/saas/components/onboarding/OnboardingTour.tsx
new file mode 100644
index 0000000000..00d65af669
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/OnboardingTour.tsx
@@ -0,0 +1,7 @@
+/**
+ * SaaS stub — core tour system is suppressed in SaaS.
+ * SaaS uses SaasOnboardingModal instead.
+ */
+export default function OnboardingTour() {
+ return null;
+}
diff --git a/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx b/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx
new file mode 100644
index 0000000000..b2f7c2711e
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/SaasOnboardingModal.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { Modal, Stack } from '@mantine/core';
+import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
+import { useTranslation } from 'react-i18next';
+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/renderButtons';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
+import { BASE_PATH } from '@app/constants/app';
+import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
+
+interface SaasOnboardingModalProps {
+ opened: boolean;
+ onClose: () => void;
+}
+
+export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
+ const { t } = useTranslation();
+ const flow = useSaasOnboardingState(props);
+
+ if (!flow) {
+ return null;
+ }
+
+ const {
+ currentStep,
+ totalSteps,
+ currentSlide,
+ slideDefinition,
+ flowState,
+ handleButtonAction,
+ } = flow;
+
+ const renderHero = () => {
+ if (slideDefinition.hero.type === 'dual-icon') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {slideDefinition.hero.type === 'rocket' && (
+
+ )}
+ {slideDefinition.hero.type === 'diamond' && }
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ {currentSlide.title}
+
+
+
+
+ {currentSlide.body}
+
+
+
+
+
+
+
+ {renderButtons({
+ slideDefinition,
+ flowState,
+ onAction: handleButtonAction,
+ t,
+ })}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/saas/components/onboarding/renderButtons.tsx b/frontend/src/saas/components/onboarding/renderButtons.tsx
new file mode 100644
index 0000000000..f1f8c7dfcd
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/renderButtons.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { Button, Group, ActionIcon } from '@mantine/core';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import { TFunction } from 'i18next';
+import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
+
+interface RenderButtonsProps {
+ slideDefinition: {
+ buttons: ButtonDefinition[];
+ id: string;
+ };
+ flowState: FlowState;
+ onAction: (action: ButtonAction) => void;
+ t: TFunction;
+}
+
+export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
+ 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) => {
+ // 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 (
+ 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' && }
+
+ );
+ }
+
+ const variant = button.variant ?? 'secondary';
+ const label = resolveButtonLabel(button);
+
+ return (
+ onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
+ {label}
+
+ );
+ };
+
+ if (leftButtons.length === 0) {
+ return {rightButtons.map(renderButton)} ;
+ }
+
+ if (rightButtons.length === 0) {
+ return {leftButtons.map(renderButton)} ;
+ }
+
+ return (
+
+ {leftButtons.map(renderButton)}
+ {rightButtons.map(renderButton)}
+
+ );
+}
diff --git a/frontend/src/saas/components/onboarding/saasFlowResolver.ts b/frontend/src/saas/components/onboarding/saasFlowResolver.ts
new file mode 100644
index 0000000000..87348f9389
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/saasFlowResolver.ts
@@ -0,0 +1,40 @@
+import { TrialStatus } from '@app/auth/UseSession';
+import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
+
+export interface FlowConfig {
+ type: 'saas-trial' | 'saas-paid';
+ ids: SlideId[];
+}
+
+/**
+ * Resolves the appropriate onboarding flow based on user's subscription status.
+ *
+ * @param trialStatus - User's trial information from Supabase
+ * @param _isPro - Whether user has Pro subscription
+ * @returns FlowConfig with the appropriate slide sequence
+ */
+export function resolveSaasFlow(
+ trialStatus: TrialStatus | null,
+ _isPro: boolean | null
+): FlowConfig {
+ // Show free trial card if:
+ // 1. User has active trial (isTrialing = true)
+ // 2. Trial has not expired (daysRemaining > 0)
+ // 3. User is not paid Pro (or Pro is from trial)
+ const hasActiveTrial =
+ trialStatus?.isTrialing === true &&
+ trialStatus.daysRemaining > 0;
+
+ if (hasActiveTrial) {
+ return {
+ type: 'saas-trial',
+ ids: FLOW_SEQUENCES.saasTrialUser,
+ };
+ }
+
+ // For paid users, expired trials, or no trial info
+ return {
+ type: 'saas-paid',
+ ids: FLOW_SEQUENCES.saasPaidUser,
+ };
+}
diff --git a/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts
new file mode 100644
index 0000000000..6987cad279
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/saasOnboardingFlowConfig.ts
@@ -0,0 +1,134 @@
+import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
+import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
+import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide';
+import { SlideConfig } from '@app/types/types';
+import { TrialStatus } from '@app/auth/UseSession';
+
+export type SlideId = 'welcome' | 'free-trial' | 'desktop-install';
+
+export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
+
+export type ButtonAction =
+ | 'next'
+ | 'prev'
+ | 'close'
+ | 'download-selected';
+
+export type FlowState = Record;
+
+export interface OSOption {
+ label: string;
+ url: string;
+ value: string;
+}
+
+export interface SlideFactoryParams {
+ osLabel: string;
+ osUrl: string;
+ osOptions?: OSOption[];
+ onDownloadUrlChange?: (url: string) => void;
+ trialStatus?: TrialStatus | null;
+}
+
+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 = {
+ 'welcome': {
+ id: 'welcome',
+ createSlide: () => WelcomeSlide(),
+ hero: { type: 'rocket' },
+ buttons: [
+ {
+ key: 'welcome-next',
+ type: 'button',
+ label: 'onboarding.buttons.next',
+ variant: 'primary',
+ group: 'right',
+ action: 'next',
+ },
+ ],
+ },
+ 'free-trial': {
+ id: 'free-trial',
+ createSlide: ({ trialStatus }) => {
+ if (!trialStatus) {
+ throw new Error('Trial status is required for free-trial slide');
+ }
+ return FreeTrialSlide({ trialStatus });
+ },
+ hero: { type: 'diamond' },
+ buttons: [
+ {
+ key: 'trial-back',
+ type: 'icon',
+ icon: 'chevron-left',
+ group: 'left',
+ action: 'prev',
+ },
+ {
+ key: 'trial-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: 'close',
+ },
+ {
+ key: 'desktop-download',
+ type: 'button',
+ label: 'onboarding.buttons.download',
+ variant: 'primary',
+ group: 'right',
+ action: 'download-selected',
+ },
+ ],
+ },
+};
+
+export const FLOW_SEQUENCES = {
+ saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
+ saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
+};
diff --git a/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx b/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx
new file mode 100644
index 0000000000..6594e0a29b
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/slides/FreeTrialSlide.tsx
@@ -0,0 +1,81 @@
+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 { TrialStatus } from '@app/auth/UseSession';
+
+interface FreeTrialSlideProps {
+ trialStatus: TrialStatus;
+}
+
+function FreeTrialSlideTitle() {
+ const { t } = useTranslation();
+
+ return (
+
+ {t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
+
+ );
+}
+
+const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
+ const { t } = useTranslation();
+
+ // Format the trial end date
+ const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+
+ // Determine which message to show based on payment method
+ const afterTrialMessage = trialStatus.hasScheduledSub
+ ? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
+ : trialStatus.hasPaymentMethod
+ ? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
+ : t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.');
+
+ // Pluralize days remaining
+ const daysText = trialStatus.daysRemaining === 1
+ ? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining })
+ : t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining });
+
+ return (
+
+
+ {t(
+ 'onboarding.freeTrial.body',
+ 'You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.'
+ )}
+
+
+
+ {daysText}
+
+
+ {t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })}
+
+
+
+ {afterTrialMessage}
+
+
+ );
+};
+
+export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
+ return {
+ key: 'free-trial',
+ title: ,
+ body: ,
+ background: {
+ gradientStops: ['#10B981', '#06B6D4'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
diff --git a/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts b/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts
new file mode 100644
index 0000000000..70f8528f62
--- /dev/null
+++ b/frontend/src/saas/components/onboarding/useSaasOnboardingState.ts
@@ -0,0 +1,172 @@
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
+import { useAuth } from '@app/auth/UseSession';
+import { useOs } from '@app/hooks/useOs';
+import {
+ SLIDE_DEFINITIONS,
+ type ButtonAction,
+ type FlowState,
+ type SlideId,
+} from '@app/components/onboarding/saasOnboardingFlowConfig';
+import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
+import { DOWNLOAD_URLS } from '@app/constants/downloads';
+
+interface UseSaasOnboardingStateResult {
+ currentStep: number;
+ totalSteps: number;
+ slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
+ currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
+ flowState: FlowState;
+ handleButtonAction: (action: ButtonAction) => void;
+}
+
+interface UseSaasOnboardingStateProps {
+ opened: boolean;
+ onClose: () => void;
+}
+
+export function useSaasOnboardingState({
+ opened,
+ onClose,
+}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
+ const { trialStatus, isPro, loading } = useAuth();
+ const osType = useOs();
+ const selectedDownloadUrlRef = useRef('');
+
+ const [currentStep, setCurrentStep] = useState(0);
+
+ // Reset state when modal closes
+ useEffect(() => {
+ if (!opened) {
+ setCurrentStep(0);
+ }
+ }, [opened]);
+
+ // Determine OS details for desktop download
+ 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);
+ }, []);
+
+ // Store selected download URL
+ const handleDownloadUrlChange = useCallback((url: string) => {
+ selectedDownloadUrlRef.current = url;
+ }, []);
+
+ // Resolve flow based on trial status
+ const resolvedFlow = useMemo(
+ () => resolveSaasFlow(trialStatus, isPro),
+ [trialStatus, isPro]
+ );
+
+ const flowSlideIds = resolvedFlow.ids;
+ const totalSteps = flowSlideIds.length;
+ const maxIndex = Math.max(totalSteps - 1, 0);
+
+ // Ensure current step is within bounds
+ useEffect(() => {
+ if (currentStep >= flowSlideIds.length) {
+ setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
+ }
+ }, [flowSlideIds.length, currentStep]);
+
+ const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
+ const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
+
+ // Create slide with appropriate params - must be called before any early returns
+ const currentSlide = useMemo(() => {
+ if (!slideDefinition) return null;
+ return slideDefinition.createSlide({
+ osLabel: os.label,
+ osUrl: os.url,
+ osOptions,
+ onDownloadUrlChange: handleDownloadUrlChange,
+ trialStatus: trialStatus ?? undefined,
+ });
+ }, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
+
+ // Navigation functions
+ const goNext = useCallback(() => {
+ setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
+ }, [maxIndex]);
+
+ const goPrev = useCallback(() => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ }, []);
+
+ // Handle button actions
+ const handleButtonAction = useCallback(
+ (action: ButtonAction) => {
+ switch (action) {
+ case 'next':
+ // If on last slide, close modal
+ if (currentStep === maxIndex) {
+ onClose();
+ } else {
+ goNext();
+ }
+ return;
+ case 'prev':
+ goPrev();
+ return;
+ case 'close':
+ onClose();
+ return;
+ case 'download-selected': {
+ // Open download URL in new tab
+ const downloadUrl = selectedDownloadUrlRef.current || os.url;
+ if (downloadUrl) {
+ window.open(downloadUrl, '_blank', 'noopener,noreferrer');
+ }
+ // Then advance to next slide or close if last
+ if (currentStep === maxIndex) {
+ onClose();
+ } else {
+ goNext();
+ }
+ return;
+ }
+ default:
+ console.warn(`Unhandled button action: ${action}`);
+ return;
+ }
+ },
+ [currentStep, maxIndex, goNext, goPrev, onClose, os.url]
+ );
+
+ const flowState: FlowState = {};
+
+ // Early return after all hooks have been called
+ if (!slideDefinition || !currentSlide || loading) {
+ return null;
+ }
+
+ return {
+ currentStep,
+ totalSteps,
+ slideDefinition,
+ currentSlide,
+ flowState,
+ handleButtonAction,
+ };
+}
diff --git a/frontend/src/saas/components/shared/AppConfigModal.tsx b/frontend/src/saas/components/shared/AppConfigModal.tsx
new file mode 100644
index 0000000000..680ceeae7b
--- /dev/null
+++ b/frontend/src/saas/components/shared/AppConfigModal.tsx
@@ -0,0 +1,238 @@
+import React, { useCallback, useMemo, useState, useEffect } from 'react';
+import { Modal, Button, Text, ActionIcon } from '@mantine/core';
+import { useMediaQuery } from '@mantine/hooks';
+import { useAuth } from '@app/auth/UseSession';
+import { isUserAnonymous } from '@app/auth/supabase';
+import { useTranslation } from 'react-i18next';
+import LocalIcon from '@app/components/shared/LocalIcon';
+import Overview from '@app/components/shared/config/configSections/Overview';
+import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections';
+import { NavKey } from '@app/components/shared/config/types';
+import { withBasePath } from '@app/constants/app';
+import '@app/components/shared/AppConfigModal.css';
+import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
+
+interface AppConfigModalProps {
+ opened: boolean;
+ onClose: () => void;
+}
+
+const AppConfigModal: React.FC = ({ opened, onClose }) => {
+ const isMobile = useMediaQuery("(max-width: 1024px)");
+
+ const { signOut, user, creditBalance, refreshCredits } = useAuth();
+ const { t } = useTranslation();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [active, setActive] = useState('overview');
+ const [notice, setNotice] = useState(null);
+
+ // Check if user can access billing features (non-anonymous users only)
+ const isAnonymous = user ? isUserAnonymous(user) : false;
+ useEffect(() => {
+ const handler = (ev: Event) => {
+ const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
+ if (detail?.key) {
+ setActive(detail.key);
+ }
+ };
+ window.addEventListener('appConfig:navigate', handler as EventListener);
+ return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
+ }, []);
+
+ // Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
+ useEffect(() => {
+ const handler = (ev: Event) => {
+ const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined;
+ if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) {
+ setNotice(detail.notice);
+ }
+ };
+ window.addEventListener('appConfig:notice', handler as EventListener);
+ return () => window.removeEventListener('appConfig:notice', handler as EventListener);
+ }, []);
+
+ // When the modal opens to Plan, proactively refresh credits and log values
+ useEffect(() => {
+ if (!opened) return;
+ if (active !== 'plan') return;
+ console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance);
+ (async () => {
+ try {
+ await refreshCredits();
+ } catch (e) {
+ console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e);
+ }
+ })();
+ }, [opened, active]);
+
+ useEffect(() => {
+ if (!opened) return;
+ if (active !== 'plan') return;
+ console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance);
+ }, [opened, active, creditBalance]);
+
+ const colors = useMemo(() => ({
+ navBg: 'var(--modal-nav-bg)',
+ sectionTitle: 'var(--modal-nav-section-title)',
+ navItem: 'var(--modal-nav-item)',
+ navItemActive: 'var(--modal-nav-item-active)',
+ navItemActiveBg: 'var(--modal-nav-item-active-bg)',
+ contentBg: 'var(--modal-content-bg)',
+ headerBorder: 'var(--modal-header-border)',
+ }), []);
+ const isDev = process.env.NODE_ENV === 'development';
+
+ const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
+
+ // Left navigation structure and icons
+ const configNavSections = useMemo(
+ () =>
+ createSaasConfigNavSections(Overview, openLogoutConfirm, {
+ isDev,
+ isAnonymous,
+ t,
+ }),
+ [openLogoutConfirm, isDev, isAnonymous, t],
+ );
+
+ const activeLabel = useMemo(() => {
+ for (const section of configNavSections) {
+ const found = section.items.find(i => i.key === active);
+ if (found) return found.label;
+ }
+ return '';
+ }, [configNavSections, active]);
+
+ const activeComponent = useMemo(() => {
+ for (const section of configNavSections) {
+ const found = section.items.find(i => i.key === active);
+ if (found) return found.component;
+ }
+ return null;
+ }, [configNavSections, active]);
+
+ return (
+ <>
+
+
+ {/* Left navigation */}
+
+
+ {configNavSections.map(section => (
+
+ {!isMobile && (
+
+ {section.title}
+
+ )}
+
+ {section.items.map(item => {
+ const isActive = active === item.key;
+ const color = isActive ? colors.navItemActive : colors.navItem;
+ const iconSize = isMobile ? 28 : 18;
+ return (
+
setActive(item.key)}
+ className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
+ style={{
+ background: isActive ? colors.navItemActiveBg : 'transparent',
+ }}
+ >
+
+ {!isMobile && (
+
+ {item.label}
+
+ )}
+
+ );
+ })}
+
+
+ ))}
+
+
+
+ {/* Right content */}
+
+
+ {/* Sticky header with section title and small close button */}
+
+
+ {activeLabel}
+ {active === 'plan' && notice ? (
+
+ – {notice}
+
+ ) : null}
+
+
+
+
+
+
+ {activeComponent}
+
+
+
+
+
+ {/* Confirm logout modal */}
+ setConfirmOpen(false)}
+ title="Sign out"
+ centered
+ zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
+ >
+
+
Are you sure you want to sign out?
+
+ setConfirmOpen(false)}>Cancel
+ {
+ try {
+ await signOut();
+ } finally {
+ setConfirmOpen(false);
+ onClose();
+ window.location.href = withBasePath('/login');
+ }
+ }}
+ >
+ Sign out
+
+
+
+
+ >
+ );
+};
+
+export default AppConfigModal;
diff --git a/frontend/src/saas/components/shared/InfoBanner.tsx b/frontend/src/saas/components/shared/InfoBanner.tsx
new file mode 100644
index 0000000000..1e5a2b712e
--- /dev/null
+++ b/frontend/src/saas/components/shared/InfoBanner.tsx
@@ -0,0 +1,179 @@
+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 | ReactNode; // SaaS supports ReactNode (e.g., logo images)
+ title?: ReactNode;
+ message: ReactNode;
+ buttonText?: string;
+ buttonIcon?: string;
+ 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';
+ buttonTextColor?: string; // SaaS-specific for dark theme buttons
+ minHeight?: number | string;
+ closeIconColor?: string; // SaaS-specific for dark theme
+}
+
+/**
+ * SaaS-specific info banner with enhanced theming support
+ * Supports ReactNode icons (e.g., logo images) and custom button text colors
+ */
+export const InfoBanner: React.FC = ({
+ 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',
+ buttonTextColor,
+ minHeight = 56,
+ closeIconColor,
+}) => {
+ if (!show) {
+ return null;
+ }
+
+ const toneStyle = toneStyles[tone] ?? toneStyles.info;
+ const handleDismiss = () => {
+ onDismiss?.();
+ };
+
+ return (
+
+
+
+ {icon && (
+ typeof icon === 'string' ? (
+
+ ) : (
+
+ {icon}
+
+ )
+ )}
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {message}
+
+
+
+
+ {buttonText && onButtonClick && (
+ }
+ styles={
+ buttonTextColor
+ ? {
+ label: {
+ color: buttonTextColor,
+ },
+ }
+ : buttonVariant !== 'white' && buttonVariant !== 'filled'
+ ? {
+ label: {
+ color: textColor ?? toneStyle.text,
+ },
+ }
+ : undefined
+ }
+ >
+ {buttonText}
+
+ )}
+ {dismissible && (
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend/src/saas/components/shared/ManageBillingButton.tsx b/frontend/src/saas/components/shared/ManageBillingButton.tsx
new file mode 100644
index 0000000000..507aeb07e0
--- /dev/null
+++ b/frontend/src/saas/components/shared/ManageBillingButton.tsx
@@ -0,0 +1,64 @@
+import { useState } from 'react';
+import { supabase } from '@app/auth/supabase';
+import { Button } from '@mantine/core';
+import { usePlans } from '@app/hooks/usePlans';
+
+interface TrialStatus {
+ isTrialing: boolean;
+ trialEnd: string;
+ daysRemaining: number;
+ hasPaymentMethod: boolean;
+ hasScheduledSub: boolean;
+}
+
+export function ManageBillingButton({
+ returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
+ children = 'Manage billing',
+ trialStatus,
+}: {
+ returnUrl?: string;
+ children?: React.ReactNode;
+ trialStatus?: TrialStatus;
+}) {
+ const [loading, setLoading] = useState(false);
+ const [err, setErr] = useState(null);
+ const { data } = usePlans();
+
+ // Hide for free plan users
+ if (!data || data.currentPlan.id === 'free') {
+ return null;
+ }
+
+ // Hide for trial users who haven't scheduled a subscription yet
+ if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) {
+ return null;
+ }
+
+ const onClick = async () => {
+ setLoading(true);
+ setErr(null);
+ try {
+ const { data, error } = await supabase.functions.invoke('manage-billing', {
+ body: {
+ name: 'Functions',
+ return_url: returnUrl},
+ })
+ if (error) throw error;
+ if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL');
+ window.location.href = (data as any).url;
+ } catch (e: any) {
+ setErr(e.message ?? 'Could not open billing portal');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {loading ? 'Opening…' : children}
+
+ {err &&
{err}
}
+
+ );
+}
diff --git a/frontend/src/saas/components/shared/PrivateContent.tsx b/frontend/src/saas/components/shared/PrivateContent.tsx
new file mode 100644
index 0000000000..e75a4d7ef5
--- /dev/null
+++ b/frontend/src/saas/components/shared/PrivateContent.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+interface PrivateContentProps extends React.HTMLAttributes {
+ children: React.ReactNode;
+}
+
+/**
+ * SaaS override of the OSS PrivateContent wrapper.
+ * Adds both the PostHog no-capture class and the Userback opt-out class
+ * while keeping the same API and layout behavior (display: contents).
+ */
+export const PrivateContent: React.FC = ({
+ children,
+ className = '',
+ style,
+ ...props
+}) => {
+ const baseClass = 'ph-no-capture userback-block';
+ const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
+ const combinedStyle = {
+ display: 'contents' as const,
+ ...style,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx
new file mode 100644
index 0000000000..3f7e13a756
--- /dev/null
+++ b/frontend/src/saas/components/shared/StripeCheckoutSaas.tsx
@@ -0,0 +1,231 @@
+import React, { useState, useEffect } from 'react';
+import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { loadStripe } from '@stripe/stripe-js';
+import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
+import { supabase } from '@app/auth/supabase';
+import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
+
+const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY);
+
+export type PurchaseType = 'subscription' | 'credits';
+export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
+export type PlanID = 'pro' | null;
+
+interface StripeCheckoutProps {
+ opened: boolean;
+ onClose: () => void;
+ // Saas-specific props
+ planId?: PlanID;
+ purchaseType?: PurchaseType;
+ creditsPack?: CreditsPack;
+ planName?: string;
+ planPrice?: number;
+ currency?: string;
+ isTrialConversion?: boolean;
+ // Proprietary-specific props (for compatibility)
+ planGroup?: any;
+ minimumSeats?: number;
+ onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
+ hostedCheckoutSuccess?: {
+ isUpgrade: boolean;
+ licenseKey?: string;
+ } | null;
+ // Common props
+ onSuccess?: (sessionId: string) => void;
+ onError?: (error: string) => void;
+}
+
+type CheckoutState = {
+ status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
+ clientSecret?: string;
+ error?: string;
+ sessionParams?: {
+ purchaseType: PurchaseType;
+ planId: PlanID;
+ creditsPack: CreditsPack;
+ };
+};
+
+const StripeCheckout: React.FC = ({
+ opened,
+ onClose,
+ planId,
+ purchaseType,
+ creditsPack,
+ planName,
+ isTrialConversion,
+ onSuccess,
+ onError
+}) => {
+ const { t } = useTranslation();
+ const [state, setState] = useState({ status: 'idle' });
+
+ const createCheckoutSession = async () => {
+ try {
+ setState({ status: 'loading' });
+
+ const { data, error } = await supabase.functions.invoke('create-checkout', {
+ body: {
+ purchase_type: purchaseType,
+ ui_mode: 'embedded',
+ plan: planId,
+ credits_pack: creditsPack,
+ callback_base_url: window.location.origin,
+ trial_conversion: isTrialConversion || false
+ }
+ });
+
+ if (error) {
+ throw new Error(error.message || 'Failed to create checkout session');
+ }
+
+ if (!data) {
+ throw new Error('No data received from server');
+ }
+
+ const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
+
+ if (!jsonData?.clientSecret) {
+ throw new Error('No client secret received from server');
+ }
+
+ setState({
+ status: 'ready',
+ clientSecret: jsonData.clientSecret,
+ sessionParams: {
+ purchaseType: purchaseType!,
+ planId: planId!,
+ creditsPack: creditsPack!
+ }
+ });
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
+ setState({
+ status: 'error',
+ error: errorMessage
+ });
+ onError?.(errorMessage);
+ }
+ };
+
+ const handlePaymentComplete = () => {
+ setState({ status: 'success' });
+
+ // Call success callback immediately - parent will handle timing
+ onSuccess?.('');
+
+ // Note: Parent (Plan.tsx) now handles the delay and modal closing
+ };
+
+ const handleClose = () => {
+ // Reset state to idle to clean up the session
+ setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
+ onClose();
+ };
+
+ // Initialize checkout when modal opens or parameters change
+ useEffect(() => {
+ if (opened) {
+ // Check if we need a new session (first time or parameters changed)
+ const needsNewSession =
+ state.status === 'idle' ||
+ !state.sessionParams ||
+ state.sessionParams.purchaseType !== purchaseType ||
+ state.sessionParams.planId !== planId ||
+ state.sessionParams.creditsPack !== creditsPack;
+
+ if (needsNewSession) {
+ console.log('Creating new checkout session:', { purchaseType, planId, creditsPack });
+ createCheckoutSession();
+ }
+ } else if (!opened) {
+ // Clean up state when modal closes
+ setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
+ }
+ }, [opened, purchaseType, planId, creditsPack]);
+
+ const renderContent = () => {
+ switch (state.status) {
+ case 'loading':
+ return (
+
+
+
+ {t('payment.preparing', 'Preparing your checkout...')}
+
+
+ );
+
+ case 'ready':
+ if (!state.clientSecret) return null;
+
+ return (
+
+
+
+ );
+
+ case 'success':
+ return (
+
+
+
+ {t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')}
+
+
+ {t('payment.autoClose', 'This window will close automatically...')}
+
+
+
+ );
+
+ case 'error':
+ return (
+
+
+ {state.error}
+
+ {t('common.close', 'Close')}
+
+
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+ {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
+
+
+ }
+ size="xl"
+ centered
+ withCloseButton={true}
+ closeOnEscape={true}
+ closeOnClickOutside={false}
+ zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
+ >
+ {renderContent()}
+
+ );
+};
+
+export default StripeCheckout;
+export { StripeCheckout };
diff --git a/frontend/src/saas/components/shared/TrialExpiredModal.tsx b/frontend/src/saas/components/shared/TrialExpiredModal.tsx
new file mode 100644
index 0000000000..578d321c73
--- /dev/null
+++ b/frontend/src/saas/components/shared/TrialExpiredModal.tsx
@@ -0,0 +1,178 @@
+import { Modal, Stack, Button } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
+import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
+
+interface TrialExpiredModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onSubscribe: () => void;
+}
+
+export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
+ const { t } = useTranslation();
+
+ // Use CSS variables for theme colors
+ const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b';
+ const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444';
+ const gradientStops: [string, string] = [amberColor, redColor];
+
+ const circles = [
+ {
+ position: 'bottom-left' as const,
+ size: 270, // 16.875rem
+ color: 'rgba(255, 255, 255, 0.25)',
+ opacity: 0.9,
+ amplitude: 24, // 1.5rem
+ duration: 4.5,
+ offsetX: 18, // 1.125rem
+ offsetY: 14, // 0.875rem
+ },
+ {
+ position: 'top-right' as const,
+ size: 300, // 18.75rem
+ color: 'rgba(255, 255, 255, 0.2)',
+ opacity: 0.9,
+ amplitude: 28, // 1.75rem
+ duration: 4.5,
+ delay: 0.5,
+ offsetX: 24, // 1.5rem
+ offsetY: 18, // 1.125rem
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/frontend/src/saas/components/shared/TrialStatusBanner.tsx b/frontend/src/saas/components/shared/TrialStatusBanner.tsx
new file mode 100644
index 0000000000..cb0f1951d6
--- /dev/null
+++ b/frontend/src/saas/components/shared/TrialStatusBanner.tsx
@@ -0,0 +1,121 @@
+import { useEffect, useState, useCallback } from 'react';
+import { useBanner } from '@app/contexts/BannerContext';
+import { useAuth } from '@app/auth/UseSession';
+import { useTranslation } from 'react-i18next';
+import { InfoBanner } from '@app/components/shared/InfoBanner';
+import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
+import { BASE_PATH } from '@app/constants/app';
+
+const SESSION_STORAGE_KEY = 'trialBannerDismissed';
+
+export function TrialStatusBanner() {
+ const { setBanner } = useBanner();
+ const { t } = useTranslation();
+ const { trialStatus } = useAuth();
+ const [dismissed, setDismissed] = useState(() => {
+ return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true';
+ });
+ const [checkoutOpen, setCheckoutOpen] = useState(false);
+
+ // Only show banner during ACTIVE trial (not after expiration - modal handles that)
+ // Don't show if payment method already added (user has scheduled subscription)
+ const shouldShowBanner =
+ trialStatus &&
+ trialStatus.isTrialing && // Only show during active trial
+ trialStatus.daysRemaining > 0 && // Trial hasn't expired yet
+ !trialStatus.hasPaymentMethod &&
+ !trialStatus.hasScheduledSub &&
+ !dismissed;
+
+ if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
+ console.log('Subscription scheduled - hiding trial banner');
+ }
+
+ const handleOpenCheckout = useCallback(() => {
+ setCheckoutOpen(true);
+ }, []);
+
+ const handleDismiss = useCallback(() => {
+ setDismissed(true);
+ sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
+ }, []);
+
+ useEffect(() => {
+ if (!shouldShowBanner) {
+ setBanner(null);
+ return;
+ }
+
+ const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
+ month: 'short',
+ day: 'numeric'
+ });
+
+ const message = t(
+ 'plan.trial.message',
+ `Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`,
+ { days: trialStatus.daysRemaining, date: trialEndDate }
+ );
+
+ const logoIcon = (
+
+ );
+
+ return () => {
+ setBanner(null);
+ };
+ }, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
+
+ const handleCheckoutSuccess = () => {
+ // Refresh to hide banner and show updated plan
+ window.location.reload();
+ };
+
+ return (
+ <>
+ {trialStatus && (
+