Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
+20 -20
View File
@@ -1,27 +1,27 @@
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 { 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 "@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';
import "@app/utils/fileIdSafety";
function handleConfigLoaded(config: AppConfig) {
if (config.baseUrl) setBaseUrl(config.baseUrl);
+425 -408
View File
@@ -1,44 +1,44 @@
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'
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
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<void>
refreshSession: () => Promise<void>
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult
updateCredits: (newBalance: number) => void
refreshCredits: () => Promise<void>
refreshProStatus: () => Promise<void>
refreshTrialStatus: () => Promise<void>
refreshProfilePicture: () => Promise<void>
refreshProfilePictureMetadata: () => Promise<void>
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<void>;
refreshSession: () => Promise<void>;
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
updateCredits: (newBalance: number) => void;
refreshCredits: () => Promise<void>;
refreshProStatus: () => Promise<void>;
refreshTrialStatus: () => Promise<void>;
refreshProfilePicture: () => Promise<void>;
refreshProfilePictureMetadata: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
@@ -61,467 +61,488 @@ const AuthContext = createContext<AuthContextType>({
refreshProStatus: async () => {},
refreshTrialStatus: async () => {},
refreshProfilePicture: async () => {},
refreshProfilePictureMetadata: async () => {}
})
refreshProfilePictureMetadata: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<AuthError | null>(null)
const [creditBalance, setCreditBalance] = useState<number | null>(null)
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null)
const [isPro, setIsPro] = useState<boolean | null>(null)
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null)
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null)
const [profilePictureMetadata, setProfilePictureMetadata] = useState<ProfilePictureMetadata | null>(null)
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
const [creditBalance, setCreditBalance] = useState<number | null>(null);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null);
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null);
const [profilePictureMetadata, setProfilePictureMetadata] = useState<ProfilePictureMetadata | null>(null);
const fetchCredits = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
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<ApiCredits>('/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
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping credit fetch");
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
return;
}
setCreditSummary(credits)
setCreditBalance(credits.creditsRemaining)
try {
console.debug("[Auth Debug] Fetching credits for user:", currentSession.user.id);
const response = await apiClient.get<ApiCredits>("/api/v1/credits");
const apiCredits = response.data;
const subscriptionInfo: SubscriptionInfo = {
status: 'active',
tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free',
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits
// 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: unknown) {
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);
}
setSubscription(subscriptionInfo)
console.debug('[Auth Debug] Credits fetched successfully:', credits)
} catch (error: unknown) {
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])
},
[session],
);
const refreshCredits = useCallback(async () => {
await fetchCredits()
}, [fetchCredits])
await fetchCredits();
}, [fetchCredits]);
const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
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)
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping pro status fetch");
setIsPro(null);
return;
}
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch pro status:', error)
setIsPro(false) // Default to false if there's an error
}
}, [session])
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: unknown) {
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])
await fetchProStatus();
}, [fetchProStatus]);
const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
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 (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping trial status fetch");
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))
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();
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)
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: unknown) {
console.debug("[Auth Debug] Failed to fetch trial status:", error);
setTrialStatus(null);
}
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch trial status:', error)
setTrialStatus(null)
}
}, [session])
},
[session],
);
const refreshTrialStatus = useCallback(async () => {
await fetchTrialStatus()
}, [fetchTrialStatus])
await fetchTrialStatus();
}, [fetchTrialStatus]);
const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
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')
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping profile picture fetch");
setProfilePictureUrl(null);
return;
}
} catch (error: unknown) {
console.debug('[Auth Debug] Failed to fetch profile picture:', error)
setProfilePictureUrl(null)
}
}, [session])
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: unknown) {
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
setProfilePictureUrl(null);
}
},
[session],
);
const refreshProfilePicture = useCallback(async () => {
await fetchProfilePicture()
}, [fetchProfilePicture])
await fetchProfilePicture();
}, [fetchProfilePicture]);
const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
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
}
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: unknown) {
console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
setProfilePictureMetadata(null)
}
}, [session])
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: unknown) {
console.debug("[Auth Debug] Failed to fetch profile picture metadata:", error);
setProfilePictureMetadata(null);
}
},
[session],
);
const refreshProfilePictureMetadata = useCallback(async () => {
await fetchProfilePictureMetadata()
}, [fetchProfilePictureMetadata])
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
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);
}
setCreditSummary(updatedSummary)
}
}, [creditSummary])
},
[creditSummary],
);
const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => {
const currentBalance = creditBalance ?? 0
const hasSufficient = currentBalance >= requiredCredits
console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient })
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])
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()
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)
console.error("[Auth Debug] Session refresh error:", error);
setError(error);
setSession(null);
} else {
console.debug('[Auth Debug] Session refreshed successfully')
setSession(data.session)
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)
console.error("[Auth Debug] Unexpected error during session refresh:", err);
setError(err as AuthError);
} finally {
setLoading(false)
setLoading(false);
}
}
};
const signOut = async () => {
try {
setError(null)
const { error } = await supabase.auth.signOut()
setError(null);
const { error } = await supabase.auth.signOut();
if (error) {
console.error('[Auth Debug] Sign out error:', error)
setError(error)
console.error("[Auth Debug] Sign out error:", error);
setError(error);
} else {
console.debug('[Auth Debug] Signed out successfully')
setSession(null)
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)
console.error("[Auth Debug] Unexpected error during sign out:", err);
setError(err as AuthError);
}
}
};
// Set up global credit update callback
useEffect(() => {
setGlobalCreditUpdateCallback(updateCredits)
}, [updateCredits])
setGlobalCreditUpdateCallback(updateCredits);
}, [updateCredits]);
useEffect(() => {
let mounted = true
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()
console.debug("[Auth Debug] Initializing auth...");
const { data, error } = await supabase.auth.getSession();
if (!mounted) return
if (!mounted) return;
if (error) {
console.error('[Auth Debug] Initial session error:', error)
setError(error)
console.error("[Auth Debug] Initial session error:", error);
setError(error);
} else {
console.debug('[Auth Debug] Initial session loaded:', {
console.debug("[Auth Debug] Initial session loaded:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email
})
setSession(data.session)
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)
})
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)
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)
fetchProfilePicture(data.session);
}, 500);
}
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during auth initialization:', err)
console.error("[Auth Debug] Unexpected error during auth initialization:", err);
if (mounted) {
setError(err as AuthError)
setError(err as AuthError);
}
} finally {
if (mounted) {
setLoading(false)
setLoading(false);
}
}
}
};
initializeAuth()
initializeAuth();
// Subscribe to auth state changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, newSession) => {
if (!mounted) return
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()
})
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)
// 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)
// 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)
// 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),
]);
}
})
// 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)
.then(() => {
console.debug("[Auth Debug] User data refreshed after upgrade");
})
}
} 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)
})
}
.catch((err) => {
console.error("[Auth Debug] Failed to synchronize user upgrade:", err);
});
}
}
}, 0)
}
)
}
}, 0);
});
return () => {
mounted = false
subscription.unsubscribe()
}
}, [])
mounted = false;
subscription.unsubscribe();
};
}, []);
const value: AuthContextType = {
session,
@@ -543,41 +564,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshProStatus,
refreshTrialStatus,
refreshProfilePicture,
refreshProfilePictureMetadata
}
refreshProfilePictureMetadata,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext)
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
throw new Error("useAuth must be used within an AuthProvider");
}
return context
return context;
}
// Debug hook to expose auth state for debugging
export function useAuthDebug() {
const auth = useAuth()
const auth = useAuth();
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
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])
provider: auth.user?.app_metadata?.provider,
});
}, [auth.session, auth.user, auth.loading, auth.error]);
return auth
return auth;
}
+76 -77
View File
@@ -1,56 +1,52 @@
import { createClient } from '@supabase/supabase-js'
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
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'
})
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 }
}
return { url, key };
};
const config = debugConfig()
const config = debugConfig();
if (!config.url) {
throw new Error('Missing VITE_SUPABASE_URL environment variable')
throw new Error("Missing VITE_SUPABASE_URL environment variable");
}
if (!config.key) {
throw new Error('Missing VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY environment variable')
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
},
}
)
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:', {
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()
})
})
}
timestamp: new Date().toISOString(),
});
});
};
// Debug auth events can be manually enabled by calling debugAuthEvents()
// Commented out to prevent excessive logging on every page load
@@ -61,105 +57,108 @@ export const debugAuthEvents = () => {
// Anonymous authentication functions
export const signInAnonymously = async () => {
try {
const { data, error } = await supabase.auth.signInAnonymously()
const { data, error } = await supabase.auth.signInAnonymously();
if (error) {
console.error('[Supabase] Anonymous sign-in error:', error)
throw error
console.error("[Supabase] Anonymous sign-in error:", error);
throw error;
}
console.log('[Supabase] Anonymous sign-in successful:', {
console.log("[Supabase] Anonymous sign-in successful:", {
userId: data.user?.id,
isAnonymous: data.user?.is_anonymous
})
isAnonymous: data.user?.is_anonymous,
});
return { data, error }
return { data, error };
} catch (error) {
console.error('[Supabase] Anonymous sign-in failed:', error)
throw 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: { email: string; password?: string } = { email }
const updateData: { email: string; password?: string } = { email };
if (password) {
updateData.password = password
updateData.password = password;
}
const { data, error } = await supabase.auth.updateUser(updateData)
const { data, error } = await supabase.auth.updateUser(updateData);
if (error) {
console.error('[Supabase] Email linking error:', error)
throw 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()
const { error: refreshError } = await supabase.auth.refreshSession();
if (refreshError) {
console.warn('[Supabase] Session refresh after email linking failed:', 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] Session refreshed after email linking");
}
console.log('[Supabase] Email linked successfully:', {
console.log("[Supabase] Email linked successfully:", {
email,
userId: data.user?.id
})
userId: data.user?.id,
});
return { data, error }
return { data, error };
} catch (error) {
console.error('[Supabase] Email linking failed:', error)
throw error
console.error("[Supabase] Email linking failed:", error);
throw error;
}
}
};
export const linkOAuthIdentity = async (provider: 'google' | 'github' | 'apple' | 'azure', redirectTo?: string) => {
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
})
options: redirectTo ? { redirectTo } : undefined,
});
if (error) {
console.error('[Supabase] OAuth linking error:', error)
throw error
console.error("[Supabase] OAuth linking error:", error);
throw error;
}
console.log('[Supabase] OAuth identity linked successfully:', {
console.log("[Supabase] OAuth identity linked successfully:", {
provider,
redirectTo,
url: data.url
})
url: data.url,
});
return { data, error }
return { data, error };
} catch (error) {
console.error('[Supabase] OAuth linking failed:', error)
throw error
console.error("[Supabase] OAuth linking failed:", error);
throw error;
}
}
};
// Helper function to check if user is anonymous
export const isUserAnonymous = (user: { is_anonymous?: boolean }) => {
return user?.is_anonymous === true
}
return user?.is_anonymous === true;
};
// Get current user session
export const getCurrentUser = async () => {
try {
const { data: { user }, error } = await supabase.auth.getUser()
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error) {
console.error('[Supabase] Get user error:', error)
throw error
console.error("[Supabase] Get user error:", error);
throw error;
}
return user
return user;
} catch (error) {
console.error('[Supabase] Get user failed:', error)
throw error
console.error("[Supabase] Get user failed:", error);
throw error;
}
}
};
@@ -1,11 +1,11 @@
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';
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';
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,
@@ -21,10 +21,10 @@ export default function OnboardingBootstrap() {
// Start polling when user logs in
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true";
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
console.debug('[Onboarding] Starting poll for trial data');
console.debug("[Onboarding] Starting poll for trial data");
setIsPolling(true);
setPollAttempts(0);
}
@@ -38,7 +38,7 @@ export default function OnboardingBootstrap() {
const timer = setTimeout(async () => {
const newAttempts = pollAttempts + 1;
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
console.debug("[Onboarding] Polling for trial data, attempt:", newAttempts);
await refreshTrialStatus();
setPollAttempts(newAttempts);
@@ -58,12 +58,12 @@ export default function OnboardingBootstrap() {
const maxAttempts = 10;
if (hasData || pollAttempts >= maxAttempts) {
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
console.debug("[Onboarding] Trial data ready or timeout, showing modal", {
hasData,
hasProStatus,
attempts: pollAttempts,
trialStatus,
isPro
isPro,
});
setIsPolling(false);
setShowModal(true);
@@ -71,7 +71,7 @@ export default function OnboardingBootstrap() {
}, [isPolling, trialStatus, isPro, pollAttempts]);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, 'true');
localStorage.setItem(STORAGE_KEY, "true");
setShowModal(false);
};
@@ -79,8 +79,8 @@ export default function OnboardingBootstrap() {
useEffect(() => {
// Ensure tool panel preference is set so tours are never deferred.
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
updatePreference("toolPanelModePromptSeen", true);
updatePreference("hasSelectedToolPanelMode", true);
}
// Clear any lingering deferred tour requests.
@@ -89,15 +89,15 @@ export default function OnboardingBootstrap() {
// In SaaS, skip the core intro onboarding entirely.
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
updatePreference("hasSeenIntroOnboarding", true);
}
// Also mark completed to avoid follow-up banners/modals.
if (!preferences.hasCompletedOnboarding) {
updatePreference('hasCompletedOnboarding', true);
updatePreference("hasCompletedOnboarding", true);
}
// Also clear any session flag that might mark onboarding as active.
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
}
}, [
@@ -1,7 +1,7 @@
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';
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
@@ -16,7 +16,7 @@ export default function TrialExpiredBootstrap() {
// Close modal if user logs out or session expires
if (!user) {
if (showModal) {
console.debug('[TrialExpired] User logged out, closing modal');
console.debug("[TrialExpired] User logged out, closing modal");
setShowModal(false);
}
if (checkoutOpened) {
@@ -32,30 +32,28 @@ export default function TrialExpiredBootstrap() {
// Build localStorage key unique to this user
const storageKey = `trialExpiredModalShown_${user.id}`;
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
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');
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 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;
const shouldShowModal = isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
if (shouldShowModal) {
console.debug('[TrialExpired] Showing trial expired modal', {
console.debug("[TrialExpired] Showing trial expired modal", {
status: trialStatus.status,
daysRemaining: trialStatus.daysRemaining,
hasPaymentMethod: trialStatus.hasPaymentMethod,
@@ -69,36 +67,32 @@ export default function TrialExpiredBootstrap() {
const handleClose = () => {
if (user) {
const storageKey = `trialExpiredModalShown_${user.id}`;
localStorage.setItem(storageKey, 'true');
console.debug('[TrialExpired] Modal dismissed, marking as seen');
localStorage.setItem(storageKey, "true");
console.debug("[TrialExpired] Modal dismissed, marking as seen");
}
setShowModal(false);
};
const handleSubscribe = () => {
console.debug('[TrialExpired] User clicked Subscribe to Pro');
console.debug("[TrialExpired] User clicked Subscribe to Pro");
setCheckoutOpened(true);
};
const handleCheckoutSuccess = () => {
console.debug('[TrialExpired] Subscription successful, refreshing page');
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');
console.debug("[TrialExpired] Checkout closed");
setCheckoutOpened(false);
};
return (
<>
<TrialExpiredModal
opened={showModal && !checkoutOpened}
onClose={handleClose}
onSubscribe={handleSubscribe}
/>
<TrialExpiredModal opened={showModal && !checkoutOpened} onClose={handleClose} onSubscribe={handleSubscribe} />
{user && (
<StripeCheckout
@@ -109,7 +103,7 @@ export default function TrialExpiredBootstrap() {
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
onError={(error) => console.error("[TrialExpired] Checkout error:", error)}
isTrialConversion={false} // Trial already ended, so this is not a conversion
/>
)}
@@ -4,9 +4,9 @@
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: 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);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
max-width: 30rem;
width: 30rem;
@@ -57,7 +57,7 @@
}
.guest-banner-dismiss:hover {
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
background: var(--mantine-color-gray-1, rgba(255, 255, 255, 0.05));
}
.guest-banner-signup {
@@ -1,88 +1,82 @@
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'
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
className?: string;
}
// Ensure the toast only appears once per full page load, not on re-hydration
let hasShownThisLoad = false
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)
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))
const isAnon = Boolean(session?.user && isUserAnonymous(session.user));
useEffect(() => {
if (!isAnon || hasShownThisLoad) return
if (!isAnon || hasShownThisLoad) return;
const timer = setTimeout(() => {
setVisible(true)
hasShownThisLoad = true
}, 2000)
setVisible(true);
hasShownThisLoad = true;
}, 2000);
return () => clearTimeout(timer)
}, [isAnon])
return () => clearTimeout(timer);
}, [isAnon]);
if (!isAnon || isDismissed || !visible) {
return null
return null;
}
const handleSignUp = () => {
window.location.href = withBasePath('/signup')
}
window.location.href = withBasePath("/signup");
};
const handleDismiss = () => {
setIsDismissed(true)
}
setIsDismissed(true);
};
return (
<div
className={`guest-banner ${className || ''}`}
role="status"
aria-live="polite"
>
<div className={`guest-banner ${className || ""}`} role="status" aria-live="polite">
<div className="guest-banner-content">
<div className="guest-banner-text">
<div className="guest-banner-title">
{t('guestBanner.title', "You're using Stirling PDF as a guest!")}
</div>
<div className="guest-banner-title">{t("guestBanner.title", "You're using Stirling PDF as a guest!")}</div>
<div className="guest-banner-message">
{t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')}
{t(
"guestBanner.message",
"Create a free account to save your work, access more features, and support the project.",
)}
</div>
</div>
<div className="guest-banner-actions">
<button
onClick={handleDismiss}
aria-label={t('guestBanner.dismiss', 'Dismiss banner')}
aria-label={t("guestBanner.dismiss", "Dismiss banner")}
className="guest-banner-dismiss"
>
<CloseIcon className="guest-banner-icon" />
</button>
<button
onClick={handleSignUp}
className="guest-banner-signup"
>
<button onClick={handleSignUp} className="guest-banner-signup">
<PersonAddIcon className="guest-banner-signup-icon" />
{t('guestBanner.signUp', 'Sign Up Free')}
{t("guestBanner.signUp", "Sign Up Free")}
</button>
</div>
</div>
</div>
)
);
}
export default GuestUserBanner
export default GuestUserBanner;
@@ -1,23 +1,23 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
interface RequireAuthProps {
fallbackPath?: string
fallbackPath?: string;
}
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
const { session, loading } = useAuth()
const location = useLocation()
const { isAutoAuthenticating } = useAutoAnonymousAuth()
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')
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 <Outlet />
console.warn("[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost");
return <Outlet />;
}
// Wait for both auth bootstrap and auto-anon to finish
@@ -29,16 +29,16 @@ export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
<p className="text-gray-600">Preparing your session</p>
</div>
</div>
)
);
}
if (!session) {
// Change the URL to /login
return <Navigate to={fallbackPath} replace state={{ from: location }} />
return <Navigate to={fallbackPath} replace state={{ from: location }} />;
}
// Render protected routes
return <Outlet />
return <Outlet />;
}
export default RequireAuth
export default RequireAuth;
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import Userback from '@userback/widget';
import { useAuth } from '@app/auth/UseSession';
import { useEffect, useRef } from "react";
import Userback from "@userback/widget";
import { useAuth } from "@app/auth/UseSession";
interface UserbackWidgetProps {
token: string;
@@ -30,14 +30,13 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
const options = {
user_data: {
id: user.id,
info: userInfo
}
info: userInfo,
},
};
// Initialize Userback
userbackRef.current = await Userback(token, options);
}
finally {
} finally {
initializingRef.current = false;
}
};
@@ -46,7 +45,7 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
// Cleanup function
return () => {
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
if (userbackRef.current && typeof userbackRef.current.destroy === "function") {
userbackRef.current.destroy();
}
initializingRef.current = false;
@@ -1,4 +1,4 @@
import UserbackWidget from '@app/components/feedback/UserbackWidget';
import UserbackWidget from "@app/components/feedback/UserbackWidget";
export function HomePageExtensions() {
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
@@ -1,15 +1,15 @@
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';
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;
@@ -24,17 +24,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
return null;
}
const {
currentStep,
totalSteps,
currentSlide,
slideDefinition,
flowState,
handleButtonAction,
} = flow;
const { currentStep, totalSteps, currentSlide, slideDefinition, flowState, handleButtonAction } = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
@@ -46,10 +39,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
</div>
);
};
@@ -66,17 +59,21 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<Stack
gap={0}
className={styles.modalContent}
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
@@ -89,20 +86,17 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
</div>
</div>
<div
<div
className={styles.modalBody}
style={{
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
<div key={`title-${currentSlide.key}`} className={`${styles.title} ${styles.titleText}`}>
{currentSlide.title}
</div>
@@ -1,8 +1,8 @@
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';
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: {
@@ -15,39 +15,39 @@ interface RenderButtonsProps {
}
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 leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
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)',
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 '';
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;
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
if (button.type === "icon") {
return (
<ActionIcon
key={button.key}
@@ -57,18 +57,18 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
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" />}
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const variant = button.variant ?? "secondary";
const label = resolveButtonLabel(button);
return (
@@ -1,8 +1,8 @@
import { TrialStatus } from '@app/auth/UseSession';
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
import { TrialStatus } from "@app/auth/UseSession";
import { FLOW_SEQUENCES, SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
export interface FlowConfig {
type: 'saas-trial' | 'saas-paid';
type: "saas-trial" | "saas-paid";
ids: SlideId[];
}
@@ -13,28 +13,23 @@ export interface FlowConfig {
* @param _isPro - Whether user has Pro subscription
* @returns FlowConfig with the appropriate slide sequence
*/
export function resolveSaasFlow(
trialStatus: TrialStatus | null,
_isPro: boolean | null
): FlowConfig {
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;
const hasActiveTrial = trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0;
if (hasActiveTrial) {
return {
type: 'saas-trial',
type: "saas-trial",
ids: FLOW_SEQUENCES.saasTrialUser,
};
}
// For paid users, expired trials, or no trial info
return {
type: 'saas-paid',
type: "saas-paid",
ids: FLOW_SEQUENCES.saasPaidUser,
};
}
@@ -1,18 +1,14 @@
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';
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 SlideId = "welcome" | "free-trial" | "desktop-install";
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
export type HeroType = "rocket" | "dual-icon" | "diamond";
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'download-selected';
export type ButtonAction = "next" | "prev" | "close" | "download-selected";
export type FlowState = Record<string, never>;
@@ -36,11 +32,11 @@ export interface HeroDefinition {
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
type: "button" | "icon";
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
@@ -53,82 +49,82 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
id: 'welcome',
welcome: {
id: "welcome",
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
key: "welcome-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'free-trial': {
id: 'free-trial',
"free-trial": {
id: "free-trial",
createSlide: ({ trialStatus }) => {
if (!trialStatus) {
throw new Error('Trial status is required for free-trial slide');
throw new Error("Trial status is required for free-trial slide");
}
return FreeTrialSlide({ trialStatus });
},
hero: { type: 'diamond' },
hero: { type: "diamond" },
buttons: [
{
key: 'trial-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
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',
key: "trial-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'desktop-install': {
id: 'desktop-install',
"desktop-install": {
id: "desktop-install",
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
hero: { type: "dual-icon" },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
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-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',
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[],
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
};
@@ -1,8 +1,8 @@
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';
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;
@@ -11,11 +11,7 @@ interface FreeTrialSlideProps {
function FreeTrialSlideTitle() {
const { t } = useTranslation();
return (
<span>
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
</span>
);
return <span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>;
}
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
@@ -23,58 +19,60 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
// Format the trial end date
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
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.')
? 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.');
? 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 });
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<p>
{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.'
"onboarding.freeTrial.body",
"You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.",
)}
</p>
<div style={{
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
padding: '1rem',
textAlign: 'center'
}}>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{daysText}
</div>
<div style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })}
<div
style={{
background: "rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "1rem",
textAlign: "center",
}}
>
<div style={{ fontSize: "1.25rem", fontWeight: "bold", marginBottom: "0.5rem" }}>{daysText}</div>
<div style={{ fontSize: "0.9rem", opacity: 0.9 }}>
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", { date: trialEndDate })}
</div>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{afterTrialMessage}
</p>
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
</div>
);
};
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
return {
key: 'free-trial',
key: "free-trial",
title: <FreeTrialSlideTitle />,
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
background: {
gradientStops: ['#10B981', '#06B6D4'],
gradientStops: ["#10B981", "#06B6D4"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
@@ -1,20 +1,20 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { useOs } from '@app/hooks/useOs';
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';
} 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']>;
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]["createSlide"]>;
flowState: FlowState;
handleButtonAction: (action: ButtonAction) => void;
}
@@ -24,13 +24,10 @@ interface UseSaasOnboardingStateProps {
onClose: () => void;
}
export function useSaasOnboardingState({
opened,
onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth();
const osType = useOs();
const selectedDownloadUrlRef = useRef<string>('');
const selectedDownloadUrlRef = useRef<string>("");
const [currentStep, setCurrentStep] = useState<number>(0);
@@ -44,28 +41,28 @@ export function useSaasOnboardingState({
// 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 };
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: '' };
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' },
{ 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);
return options.filter((opt) => opt.url);
}, []);
// Store selected download URL
@@ -74,10 +71,7 @@ export function useSaasOnboardingState({
}, []);
// Resolve flow based on trial status
const resolvedFlow = useMemo(
() => resolveSaasFlow(trialStatus, isPro),
[trialStatus, isPro]
);
const resolvedFlow = useMemo(() => resolveSaasFlow(trialStatus, isPro), [trialStatus, isPro]);
const flowSlideIds = resolvedFlow.ids;
const totalSteps = flowSlideIds.length;
@@ -118,7 +112,7 @@ export function useSaasOnboardingState({
const handleButtonAction = useCallback(
(action: ButtonAction) => {
switch (action) {
case 'next':
case "next":
// If on last slide, close modal
if (currentStep === maxIndex) {
onClose();
@@ -126,17 +120,17 @@ export function useSaasOnboardingState({
goNext();
}
return;
case 'prev':
case "prev":
goPrev();
return;
case 'close':
case "close":
onClose();
return;
case 'download-selected': {
case "download-selected": {
// Open download URL in new tab
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
@@ -151,7 +145,7 @@ export function useSaasOnboardingState({
return;
}
},
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
[currentStep, maxIndex, goNext, goPrev, onClose, os.url],
);
const flowState: FlowState = {};
@@ -1,16 +1,16 @@
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';
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;
@@ -23,7 +23,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const { signOut, user, creditBalance, refreshCredits } = useAuth();
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>('overview');
const [active, setActive] = useState<NavKey>("overview");
const [notice, setNotice] = useState<string | null>(null);
// Check if user can access billing features (non-anonymous users only)
@@ -35,52 +35,55 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
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)) {
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);
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);
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);
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);
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 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), []);
@@ -97,15 +100,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.label;
}
return '';
return "";
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.component;
}
return null;
@@ -114,95 +117,96 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? "mobile" : ""}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map((section) => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text
size="xs"
fw={600}
c={colors.sectionTitle}
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map((item) => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
style={{
background: isActive ? colors.navItemActiveBg : "transparent",
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">
{activeLabel}
{active === 'plan' && notice ? (
<span style={{ marginLeft: 8, fontWeight: 600, color: 'var(--mantine-color-yellow-7)' }}>
{notice}
</span>
) : null}
</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">
{activeLabel}
{active === "plan" && notice ? (
<span style={{ marginLeft: 8, fontWeight: 600, color: "var(--mantine-color-yellow-7)" }}> {notice}</span>
) : null}
</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">{activeComponent}</div>
</div>
</div>
</div>
</div>
</Modal>
{/* Confirm logout modal */}
{/* Confirm logout modal */}
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
@@ -213,7 +217,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div className="confirm-modal-content">
<Text>Are you sure you want to sign out?</Text>
<div className="confirm-modal-buttons">
<Button variant="default" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
color="red"
onClick={async () => {
@@ -222,7 +228,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
} finally {
setConfirmOpen(false);
onClose();
window.location.href = withBasePath('/login');
window.location.href = withBasePath("/login");
}
}}
>
@@ -1,8 +1,8 @@
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
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';
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
@@ -15,18 +15,18 @@ const toneStyles: Record<
}
> = {
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',
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',
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",
},
};
@@ -47,7 +47,7 @@ interface InfoBannerProps {
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
buttonTextColor?: string; // SaaS-specific for dark theme buttons
minHeight?: number | string;
closeIconColor?: string; // SaaS-specific for dark theme
@@ -62,19 +62,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
buttonIcon = "check-circle-rounded",
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
@@ -96,14 +96,14 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
}}
>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<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 }}>
{icon && (
typeof icon === 'string' ? (
{icon &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width="1.2rem"
@@ -111,23 +111,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{icon}
</div>
)
)}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>{icon}</div>
))}
<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}
>
<Text fw={title ? 400 : 500} size="sm" style={{ color: textColor ?? toneStyle.text }} lineClamp={2}>
{message}
</Text>
</Stack>
@@ -148,13 +140,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
color: buttonTextColor,
},
}
: buttonVariant !== 'white' && buttonVariant !== 'filled'
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
: buttonVariant !== "white" && buttonVariant !== "filled"
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
}
>
{buttonText}
@@ -163,7 +155,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { Button } from '@mantine/core';
import { usePlans } from '@app/hooks/usePlans';
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;
@@ -12,8 +12,8 @@ interface TrialStatus {
}
export function ManageBillingButton({
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
children = 'Manage billing',
returnUrl = typeof window !== "undefined" ? window.location.href : "/",
children = "Manage billing",
trialStatus,
}: {
returnUrl?: string;
@@ -25,7 +25,7 @@ export function ManageBillingButton({
const { data } = usePlans();
// Hide for free plan users
if (!data || data.currentPlan.id === 'free') {
if (!data || data.currentPlan.id === "free") {
return null;
}
@@ -38,16 +38,17 @@ export function ManageBillingButton({
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>('manage-billing', {
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>("manage-billing", {
body: {
name: 'Functions',
return_url: returnUrl},
})
name: "Functions",
return_url: returnUrl,
},
});
if (error) throw error;
if (!data || 'error' in data) throw new Error(data?.error ?? 'No portal URL');
if (!data || "error" in data) throw new Error(data?.error ?? "No portal URL");
window.location.href = data.url;
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Could not open billing portal');
setErr(e instanceof Error ? e.message : "Could not open billing portal");
} finally {
setLoading(false);
}
@@ -56,7 +57,7 @@ export function ManageBillingButton({
return (
<div>
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
{loading ? 'Opening…' : children}
{loading ? "Opening…" : children}
</Button>
{err && <div className="mt-2 text-red-600">{err}</div>}
</div>
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
@@ -9,16 +9,11 @@ interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
* 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<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const baseClass = 'ph-no-capture userback-block';
export const PrivateContent: React.FC<PrivateContentProps> = ({ children, className = "", style, ...props }) => {
const baseClass = "ph-no-capture userback-block";
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
const combinedStyle = {
display: 'contents' as const,
display: "contents" as const,
...style,
};
@@ -1,16 +1,16 @@
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';
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_KEY);
export type PurchaseType = 'subscription' | 'credits';
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
export type PlanID = 'pro' | null;
export type PurchaseType = "subscription" | "credits";
export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null;
export type PlanID = "pro" | null;
interface StripeCheckoutProps {
opened: boolean;
@@ -26,7 +26,7 @@ interface StripeCheckoutProps {
// Proprietary-specific props (for compatibility)
planGroup?: unknown;
minimumSeats?: number;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
@@ -37,7 +37,7 @@ interface StripeCheckoutProps {
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
status: "idle" | "loading" | "ready" | "success" | "error";
clientSecret?: string;
error?: string;
sessionParams?: {
@@ -56,71 +56,71 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
planName,
isTrialConversion,
onSuccess,
onError
onError,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const [state, setState] = useState<CheckoutState>({ status: "idle" });
const createCheckoutSession = async () => {
try {
setState({ status: 'loading' });
setState({ status: "loading" });
const { data, error } = await supabase.functions.invoke('create-checkout', {
const { data, error } = await supabase.functions.invoke("create-checkout", {
body: {
purchase_type: purchaseType,
ui_mode: 'embedded',
ui_mode: "embedded",
plan: planId,
credits_pack: creditsPack,
callback_base_url: window.location.origin,
trial_conversion: isTrialConversion || false
}
trial_conversion: isTrialConversion || false,
},
});
if (error) {
throw new Error(error.message || 'Failed to create checkout session');
throw new Error(error.message || "Failed to create checkout session");
}
if (!data) {
throw new Error('No data received from server');
throw new Error("No data received from server");
}
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
const jsonData = typeof data === "string" ? JSON.parse(data) : data;
if (!jsonData?.clientSecret) {
throw new Error('No client secret received from server');
throw new Error("No client secret received from server");
}
setState({
status: 'ready',
status: "ready",
clientSecret: jsonData.clientSecret,
sessionParams: {
purchaseType: purchaseType!,
planId: planId!,
creditsPack: creditsPack!
}
creditsPack: creditsPack!,
},
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
setState({
status: 'error',
error: errorMessage
status: "error",
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handlePaymentComplete = () => {
setState({ status: 'success' });
setState({ status: "success" });
// Call success callback immediately - parent will handle timing
onSuccess?.('');
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 });
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
onClose();
};
@@ -129,35 +129,35 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
if (opened) {
// Check if we need a new session (first time or parameters changed)
const needsNewSession =
state.status === 'idle' ||
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 });
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 });
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
}
}, [opened, purchaseType, planId, creditsPack]);
const renderContent = () => {
switch (state.status) {
case 'loading':
case "loading":
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</div>
);
case 'ready':
case "ready":
if (!state.clientSecret) return null;
return (
@@ -166,34 +166,37 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
case 'success':
case "success":
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
<Stack gap="md">
<Text size="sm">
{t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')}
{t(
"payment.successMessage",
"Your plan has been upgraded successfully. You will receive a confirmation email shortly.",
)}
</Text>
<Text size="xs" c="dimmed">
{t('payment.autoClose', 'This window will close automatically...')}
{t("payment.autoClose", "This window will close automatically...")}
</Text>
</Stack>
</Alert>
);
case 'error':
case "error":
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -211,7 +214,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName })}
</Text>
</div>
}
@@ -1,9 +1,9 @@
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';
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;
@@ -15,15 +15,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
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 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,
position: "bottom-left" as const,
size: 270, // 16.875rem
color: 'rgba(255, 255, 255, 0.25)',
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24, // 1.5rem
duration: 4.5,
@@ -31,9 +31,9 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
offsetY: 14, // 0.875rem
},
{
position: 'top-right' as const,
position: "top-right" as const,
size: 300, // 18.75rem
color: 'rgba(255, 255, 255, 0.2)',
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28, // 1.75rem
duration: 4.5,
@@ -57,26 +57,25 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<Stack
gap={0}
className={styles.modalContent}
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={gradientStops}
circles={circles}
isActive
slideKey="trial-expired"
/>
<AnimatedSlideBackground gradientStops={gradientStops} circles={circles} isActive slideKey="trial-expired" />
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
</div>
</div>
</div>
@@ -85,28 +84,26 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{t('plan.trial.expired', 'Your Trial Has Ended')}
</div>
<div className={`${styles.title} ${styles.titleText}`}>{t("plan.trial.expired", "Your Trial Has Ended")}</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
'plan.trial.expiredMessage',
'Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.'
"plan.trial.expiredMessage",
"Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.",
)}
</div>
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t('plan.trial.freeTierLimitations', 'Free tier includes basic PDF tools with usage limits.')}
{t("plan.trial.freeTierLimitations", "Free tier includes basic PDF tools with usage limits.")}
</div>
</div>
@@ -127,10 +124,10 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
<div
className="trial-button-container"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
justifyContent: 'space-between',
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
justifyContent: "space-between",
}}
>
<Button
@@ -139,15 +136,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
size="sm"
className="trial-modal-button"
style={{
fontSize: '0.8125rem',
padding: '0.5rem 1rem',
height: 'auto',
minWidth: '8.125rem',
flex: '0 1 auto',
border: '0'
fontSize: "0.8125rem",
padding: "0.5rem 1rem",
height: "auto",
minWidth: "8.125rem",
flex: "0 1 auto",
border: "0",
}}
>
{t('plan.trial.continueWithFree', 'Continue with Free')}
{t("plan.trial.continueWithFree", "Continue with Free")}
</Button>
<Button
@@ -155,18 +152,18 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
size="md"
className="trial-modal-button trial-modal-button-primary"
style={{
background: 'linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))',
color: '#FFFFFF',
fontSize: '0.9375rem',
background: "linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))",
color: "#FFFFFF",
fontSize: "0.9375rem",
fontWeight: 600,
padding: '0.75rem 1.5rem',
height: 'auto',
border: 'none',
minWidth: '10.625rem',
flex: '0 1 auto',
padding: "0.75rem 1.5rem",
height: "auto",
border: "none",
minWidth: "10.625rem",
flex: "0 1 auto",
}}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
</div>
</div>
@@ -1,19 +1,19 @@
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';
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';
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';
return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true";
});
const [checkoutOpen, setCheckoutOpen] = useState(false);
@@ -28,7 +28,7 @@ export function TrialStatusBanner() {
!dismissed;
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
console.log('Subscription scheduled - hiding trial banner');
console.log("Subscription scheduled - hiding trial banner");
}
const handleOpenCheckout = useCallback(() => {
@@ -37,7 +37,7 @@ export function TrialStatusBanner() {
const handleDismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
sessionStorage.setItem(SESSION_STORAGE_KEY, "true");
}, []);
useEffect(() => {
@@ -46,15 +46,15 @@ export function TrialStatusBanner() {
return;
}
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
month: 'short',
day: 'numeric'
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 }
"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 = (
@@ -62,9 +62,9 @@ export function TrialStatusBanner() {
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling PDF"
style={{
width: '1.5rem',
height: '1.5rem',
objectFit: 'contain'
width: "1.5rem",
height: "1.5rem",
objectFit: "contain",
}}
/>
);
@@ -74,7 +74,7 @@ export function TrialStatusBanner() {
icon={logoIcon}
tone="info"
message={message}
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
buttonText={t("plan.trial.subscribe", "Subscribe to Pro")}
buttonIcon="credit-card-rounded"
onButtonClick={handleOpenCheckout}
onDismiss={handleDismiss}
@@ -88,7 +88,7 @@ export function TrialStatusBanner() {
buttonVariant="white"
buttonTextColor="var(--mantine-color-dark-9)"
closeIconColor="rgba(255, 255, 255, 0.7)"
/>
/>,
);
return () => {
@@ -112,7 +112,7 @@ export function TrialStatusBanner() {
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('Checkout error:', error)}
onError={(error) => console.error("Checkout error:", error)}
isTrialConversion={true}
/>
)}
@@ -1,19 +1,19 @@
import React, { useEffect, useMemo, useRef, useCallback, useId } from 'react';
import { Group, Loader, Text } from '@mantine/core';
import * as d3 from 'd3';
import { StackedBarChartProps, TooltipData, FractionData } from '@app/types/charts';
import { generateTooltipHTML } from '@app/components/shared/charts/stackedBarChart/StackedBarTooltip';
import { detectTheme, getChartThemeVars } from '@app/components/shared/charts/utils/themeUtils';
import { createTooltipPositioner } from '@app/components/shared/charts/utils/tooltipUtils';
import { createRoundedRectPath, createScale } from '@app/components/shared/charts/utils/d3Utils';
import React, { useEffect, useMemo, useRef, useCallback, useId } from "react";
import { Group, Loader, Text } from "@mantine/core";
import * as d3 from "d3";
import { StackedBarChartProps, TooltipData, FractionData } from "@app/types/charts";
import { generateTooltipHTML } from "@app/components/shared/charts/stackedBarChart/StackedBarTooltip";
import { detectTheme, getChartThemeVars } from "@app/components/shared/charts/utils/themeUtils";
import { createTooltipPositioner } from "@app/components/shared/charts/utils/tooltipUtils";
import { createRoundedRectPath, createScale } from "@app/components/shared/charts/utils/d3Utils";
export default function StackedBarChart({
fractions,
width = 640,
height = 22,
export default function StackedBarChart({
fractions,
width = 640,
height = 22,
showLegend = true,
className = '',
tooltipPosition = 'top',
className = "",
tooltipPosition = "top",
loading = false,
animate = true,
animationDurationMs = 900,
@@ -32,96 +32,102 @@ export default function StackedBarChart({
// Memoize tooltip positioner
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
const positionTooltip = useCallback((event: MouseEvent) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
}, [tooltipPositioner]);
const positionTooltip = useCallback(
(event: MouseEvent) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
},
[tooltipPositioner],
);
const setTooltipContent = useCallback((labelHtml: string) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.innerHTML = labelHtml;
tooltip.style.background = themeVars.background;
tooltip.style.color = themeVars.textPrimary;
tooltip.style.border = themeVars.border;
tooltip.style.boxShadow = themeVars.boxShadow;
tooltip.style.padding = '8px 10px';
tooltip.style.fontSize = '12px';
tooltip.style.lineHeight = '1.25';
tooltip.style.borderRadius = '8px';
}, [themeVars]);
const setTooltipContent = useCallback(
(labelHtml: string) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.innerHTML = labelHtml;
tooltip.style.background = themeVars.background;
tooltip.style.color = themeVars.textPrimary;
tooltip.style.border = themeVars.border;
tooltip.style.boxShadow = themeVars.boxShadow;
tooltip.style.padding = "8px 10px";
tooltip.style.fontSize = "12px";
tooltip.style.lineHeight = "1.25";
tooltip.style.borderRadius = "8px";
},
[themeVars],
);
const hideTooltip = useCallback(() => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.style.opacity = '0';
tooltip.style.opacity = "0";
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
container.innerHTML = "";
// Calculate total capacity (sum of all denominators)
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
if (totalCapacity === 0 && !loading) return;
// Create data for the bar segments
const data = fractions.map((fraction: FractionData) => ({
...fraction,
value: fraction.numerator,
remaining: fraction.denominator - fraction.numerator
remaining: fraction.denominator - fraction.numerator,
}));
const radius = 8;
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('role', ariaLabel ? 'img' : null)
.attr('aria-label', ariaLabel || null);
.append("svg")
.attr("width", "100%")
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("role", ariaLabel ? "img" : null)
.attr("aria-label", ariaLabel || null);
const x = createScale([0, totalCapacity], [0, width]);
let cursor = 0;
const g = svg.append('g');
const g = svg.append("g");
// Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner
if (!loading) {
// Create a single rounded rectangle for the entire bar background
const totalBarWidth = x(totalCapacity);
g.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', themeVars.inactive)
.attr('stroke', themeVars.cardBorder);
g.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", totalBarWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", themeVars.inactive)
.attr("stroke", themeVars.cardBorder);
// Define a clipPath that will reveal the used portion from left to right
const defs = svg.append('defs');
const defs = svg.append("defs");
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius);
.append("clipPath")
.attr("id", clipId)
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 0)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius);
// Group to hold the used segments and apply clip-path
const usedGroup = svg.append('g').attr('clip-path', `url(#${clipId})`);
const usedGroup = svg.append("g").attr("clip-path", `url(#${clipId})`);
// Render used segments on top of the background
data.forEach((fraction: typeof data[number], index: number) => {
data.forEach((fraction: (typeof data)[number], index: number) => {
if (fraction.value <= 0) return;
const segWidth = x(fraction.value);
@@ -133,131 +139,145 @@ export default function StackedBarChart({
if (isFirst && isLast) {
// Single segment: fully rounded
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', fraction.color);
usedGroup
.append("rect")
.attr("x", xPos)
.attr("y", 0)
.attr("width", segWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", fraction.color);
} else if (isFirst) {
// First segment: rounded on left side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: true,
topRight: false,
bottomLeft: true,
bottomRight: false
bottomRight: false,
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
} else if (isLast) {
// Last segment: rounded on right side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: false,
topRight: true,
bottomLeft: false,
bottomRight: true
bottomRight: true,
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
} else {
// Middle segments: no rounded edges
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('fill', fraction.color);
usedGroup
.append("rect")
.attr("x", xPos)
.attr("y", 0)
.attr("width", segWidth)
.attr("height", height)
.attr("fill", fraction.color);
}
});
// Add a transparent overlay for hover events on the entire bar (outside clip path)
svg.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', 'transparent')
.style('pointer-events', 'all')
.on('mouseenter', (event: MouseEvent) => {
svg
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", totalBarWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", "transparent")
.style("pointer-events", "all")
.on("mouseenter", (event: MouseEvent) => {
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
const html = generateTooltipHTML(tooltipData);
setTooltipContent(html);
const tooltip = tooltipRef.current;
if (tooltip) tooltip.style.opacity = '1';
if (tooltip) tooltip.style.opacity = "1";
positionTooltip(event as unknown as MouseEvent);
})
.on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on('mouseleave', hideTooltip);
.on("mousemove", (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on("mouseleave", hideTooltip);
// Animate reveal of used segments (only on first load, not on re-renders)
const totalUsed = data.reduce((sum: number, f: typeof data[number]) => sum + f.value, 0);
const totalUsed = data.reduce((sum: number, f: (typeof data)[number]) => sum + f.value, 0);
const revealTo = x(totalUsed);
if (animate && !hasAnimatedRef.current) {
clipRect.transition().duration(animationDurationMs).attr('width', revealTo);
clipRect.transition().duration(animationDurationMs).attr("width", revealTo);
hasAnimatedRef.current = true;
} else {
clipRect.attr('width', revealTo);
clipRect.attr("width", revealTo);
}
}
return () => { container.innerHTML = ''; };
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
return () => {
container.innerHTML = "";
};
}, [
fractions,
width,
height,
tooltipPosition,
loading,
animate,
animationDurationMs,
clipId,
themeVars,
setTooltipContent,
hideTooltip,
positionTooltip,
]);
return (
<div className={className}>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<div ref={containerRef} />
<div
ref={tooltipRef}
style={{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 120ms ease',
zIndex: 1000
style={{
position: "absolute",
left: 0,
top: 0,
pointerEvents: "none",
opacity: 0,
transition: "opacity 120ms ease",
zIndex: 1000,
}}
/>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Loader size="sm" color="blue" />
</div>
)}
</div>
{showLegend && (
<Group gap="lg" mt="sm">
{fractions.map((fraction: FractionData, index: number) => (
<Group key={index} gap={6}>
<span
style={{
width: 10,
height: 10,
background: fraction.color,
display: 'inline-block',
borderRadius: 2
}}
<span
style={{
width: 10,
height: 10,
background: fraction.color,
display: "inline-block",
borderRadius: 2,
}}
/>
<Text size="sm">{fraction.name}</Text>
</Group>
))}
<Group gap={6}>
<span
style={{
width: 10,
height: 10,
background: themeVars.inactive,
display: 'inline-block',
borderRadius: 2,
outline: `1px solid ${themeVars.cardBorder}`
}}
<span
style={{
width: 10,
height: 10,
background: themeVars.inactive,
display: "inline-block",
borderRadius: 2,
outline: `1px solid ${themeVars.cardBorder}`,
}}
/>
<Text size="sm">Remaining</Text>
</Group>
@@ -1,5 +1,5 @@
import React from 'react';
import { TooltipData } from '@app/types/charts';
import React from "react";
import { TooltipData } from "@app/types/charts";
interface StackedBarTooltipProps {
data: TooltipData;
@@ -9,16 +9,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
const { fractions } = data;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', whiteSpace: 'nowrap' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", whiteSpace: "nowrap" }}>
{fractions.map((f, index) => (
<div key={index} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span style={{
display: 'inline-block',
width: '10px',
height: '10px',
background: f.color,
borderRadius: '2px'
}}></span>
<div key={index} style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<span
style={{
display: "inline-block",
width: "10px",
height: "10px",
background: f.color,
borderRadius: "2px",
}}
></span>
<span>
<strong>{f.name}</strong> {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
</span>
@@ -30,14 +32,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
export function generateTooltipHTML(data: TooltipData): string {
const { fractions } = data;
return `
<div style="display:flex;flex-direction:column;gap:6px;white-space:nowrap;">
${fractions.map(f => `
${fractions
.map(
(f) => `
<div style="display:flex;gap:8px;align-items:center;">
<span style="display:inline-block;width:10px;height:10px;background:${f.color};border-radius:2px"></span>
<span><strong>${f.name}</strong> — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)}</span>
</div>
`).join('')}
`,
)
.join("")}
</div>`;
}
@@ -2,7 +2,7 @@
* Reusable D3 utility functions for chart creation
*/
import * as d3 from 'd3';
import * as d3 from "d3";
export interface ChartDimensions {
width: number;
@@ -28,17 +28,17 @@ export interface AnimationConfig {
* @returns The created SVG selection
*/
export function createSVG(
container: HTMLElement,
container: HTMLElement,
dimensions: ChartDimensions,
className?: string
className?: string,
): d3.Selection<SVGSVGElement, unknown, null, undefined> {
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', dimensions.height)
.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)
.attr('class', className || '');
.append("svg")
.attr("width", "100%")
.attr("height", dimensions.height)
.attr("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`)
.attr("class", className || "");
return svg;
}
@@ -53,17 +53,17 @@ export function createSVG(
export function createClipPath(
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
clipId: string,
dimensions: ChartDimensions
dimensions: ChartDimensions,
): d3.Selection<SVGRectElement, unknown, null, undefined> {
const defs = svg.append('defs');
const defs = svg.append("defs");
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', dimensions.height);
.append("clipPath")
.attr("id", clipId)
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 0)
.attr("height", dimensions.height);
return clipRect;
}
@@ -77,13 +77,13 @@ export function createClipPath(
export function animateClipReveal(
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
targetWidth: number,
config: AnimationConfig
config: AnimationConfig,
): void {
clipRect
.transition()
.duration(config.duration)
.ease(config.easing || d3.easeCubicInOut)
.attr('width', targetWidth);
.attr("width", targetWidth);
}
/**
@@ -102,48 +102,48 @@ export function createRoundedRectPath(
width: number,
height: number,
radius: number,
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {}
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {},
): string {
const { topLeft = true, topRight = true, bottomLeft = true, bottomRight = true } = corners;
if (width <= 0 || height <= 0) return '';
if (width <= 0 || height <= 0) return "";
const topLeftRadius = topLeft ? radius : 0;
const topRightRadius = topRight ? radius : 0;
const bottomRightRadius = bottomRight ? radius : 0;
const bottomLeftRadius = bottomLeft ? radius : 0;
let path = `M ${x + topLeftRadius} ${y}`;
if (topRight) {
path += ` L ${x + width - topRightRadius} ${y}`;
path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`;
} else {
path += ` L ${x + width} ${y}`;
}
if (bottomRight) {
path += ` L ${x + width} ${y + height - bottomRightRadius}`;
path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`;
} else {
path += ` L ${x + width} ${y + height}`;
}
if (bottomLeft) {
path += ` L ${x + bottomLeftRadius} ${y + height}`;
path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`;
} else {
path += ` L ${x} ${y + height}`;
}
if (topLeft) {
path += ` L ${x} ${y + topLeftRadius}`;
path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
return path + ' Z';
return path + " Z";
}
/**
@@ -162,10 +162,7 @@ export function createScale(domain: [number, number], range: [number, number]) {
* @param wait The wait time in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
export function debounce<T extends (...args: unknown[]) => unknown>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
@@ -13,18 +13,17 @@ export interface ThemeInfo {
* @returns ThemeInfo object with theme detection results
*/
export function detectTheme(): ThemeInfo {
const rootEl = typeof document !== 'undefined' ? document.documentElement : null;
const schemeAttr = rootEl?.getAttribute('data-mantine-color-scheme');
const prefersDark = typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = schemeAttr ? schemeAttr === 'dark' : prefersDark;
const rootEl = typeof document !== "undefined" ? document.documentElement : null;
const schemeAttr = rootEl?.getAttribute("data-mantine-color-scheme");
const prefersDark =
typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = schemeAttr ? schemeAttr === "dark" : prefersDark;
return {
isDark,
schemeAttr,
prefersDark
prefersDark,
};
}
@@ -35,12 +34,12 @@ export function detectTheme(): ThemeInfo {
*/
export function getChartThemeVars(isDark: boolean) {
return {
background: 'var(--bg-surface)',
textPrimary: 'var(--text-primary)',
border: isDark ? '1px solid var(--border-subtle)' : '1px solid transparent',
boxShadow: isDark ? 'none' : 'var(--shadow-md)',
inactive: 'var(--usage-inactive)',
cardBorder: 'var(--api-keys-card-border)'
background: "var(--bg-surface)",
textPrimary: "var(--text-primary)",
border: isDark ? "1px solid var(--border-subtle)" : "1px solid transparent",
boxShadow: isDark ? "none" : "var(--shadow-md)",
inactive: "var(--usage-inactive)",
cardBorder: "var(--api-keys-card-border)",
};
}
@@ -51,13 +50,13 @@ export function getChartThemeVars(isDark: boolean) {
*/
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
const themeVars = getChartThemeVars(isDark);
tooltipElement.style.background = themeVars.background;
tooltipElement.style.color = themeVars.textPrimary;
tooltipElement.style.border = themeVars.border;
tooltipElement.style.boxShadow = themeVars.boxShadow;
tooltipElement.style.padding = '8px 10px';
tooltipElement.style.fontSize = '12px';
tooltipElement.style.lineHeight = '1.25';
tooltipElement.style.borderRadius = '8px';
tooltipElement.style.padding = "8px 10px";
tooltipElement.style.fontSize = "12px";
tooltipElement.style.lineHeight = "1.25";
tooltipElement.style.borderRadius = "8px";
}
@@ -2,7 +2,7 @@
* Utility functions for tooltip positioning and management in D3 charts
*/
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export type TooltipPosition = "top" | "bottom" | "left" | "right";
export interface TooltipPositioner {
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
@@ -19,36 +19,35 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
const bounds = container.getBoundingClientRect();
const offsetX = event.clientX - bounds.left;
const offsetY = event.clientY - bounds.top;
// Get tooltip dimensions after content is set
const tooltipHeight = tooltip.offsetHeight;
const tooltipWidth = tooltip.offsetWidth;
const gap = 16; // 1rem gap
// Position tooltip based on the specified position
switch (position) {
case 'top':
case "top":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY - tooltipHeight - gap}px`;
break;
case 'bottom':
case "bottom":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY + gap}px`;
break;
case 'left':
case "left":
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
case 'right':
case "right":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
}
};
const hideTooltip = (tooltip: HTMLElement) => {
tooltip.style.opacity = '0';
tooltip.style.opacity = "0";
};
return { positionTooltip, hideTooltip };
@@ -60,15 +59,15 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
* @returns The created tooltip element
*/
export function createTooltipElement(container: HTMLElement): HTMLElement {
const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.left = '0';
tooltip.style.top = '0';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.transition = 'opacity 120ms ease';
tooltip.style.zIndex = '1000';
const tooltip = document.createElement("div");
tooltip.style.position = "absolute";
tooltip.style.left = "0";
tooltip.style.top = "0";
tooltip.style.pointerEvents = "none";
tooltip.style.opacity = "0";
tooltip.style.transition = "opacity 120ms ease";
tooltip.style.zIndex = "1000";
container.appendChild(tooltip);
return tooltip;
}
@@ -1,9 +1,9 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal, Button, Stack, Slider, Alert, Text, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import Cropper from 'react-easy-crop';
import { getCroppedImage, type Area } from '@app/utils/cropImage';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import React, { useState, useCallback, useEffect } from "react";
import { Modal, Button, Stack, Slider, Alert, Text, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import Cropper from "react-easy-crop";
import { getCroppedImage, type Area } from "@app/utils/cropImage";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ProfilePictureCropperProps {
file: File | null;
@@ -12,12 +12,7 @@ interface ProfilePictureCropperProps {
onCropComplete: (croppedBlob: Blob) => void;
}
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
file,
opened,
onClose,
onCropComplete,
}) => {
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ file, opened, onClose, onCropComplete }) => {
const { t } = useTranslation();
// State management
@@ -41,7 +36,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
setError(null);
};
reader.onerror = () => {
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
setError(
t(
"config.account.profilePicture.cropper.invalidImage",
"Invalid image file. Please select a valid PNG, JPG, or WebP file.",
),
);
};
reader.readAsDataURL(file);
@@ -75,12 +75,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
}, []);
// Called when crop is complete (stores the crop area in pixels)
const onCropCompleteCallback = useCallback(
(_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
const onCropCompleteCallback = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
// Process and save the cropped image
const handleSave = async () => {
@@ -100,9 +97,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
if (croppedBlob.size > maxSize) {
setError(
t(
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
'Cropped image is too large. Please zoom out or crop a smaller area.'
)
"config.account.profilePicture.cropper.sizeErrorAfterCrop",
"Cropped image is too large. Please zoom out or crop a smaller area.",
),
);
setProcessing(false);
return;
@@ -112,13 +109,8 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
onCropComplete(croppedBlob);
onClose();
} catch (err) {
console.error('Error cropping image:', err);
setError(
t(
'config.account.profilePicture.cropper.cropError',
'Failed to crop image. Please try again.'
)
);
console.error("Error cropping image:", err);
setError(t("config.account.profilePicture.cropper.cropError", "Failed to crop image. Please try again."));
} finally {
setProcessing(false);
}
@@ -128,7 +120,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
<Modal
opened={opened}
onClose={onClose}
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
title={t("config.account.profilePicture.cropper.title", "Crop Profile Picture")}
size="lg"
centered
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
@@ -141,7 +133,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
)}
{/* Cropper area */}
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
<Box style={{ position: "relative", width: "100%", height: 400 }}>
{imageSrc && (
<Cropper
image={imageSrc}
@@ -158,27 +150,20 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
{/* Zoom slider */}
<Stack gap={4}>
<Text size="sm" fw={500}>
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
{t("config.account.profilePicture.cropper.zoom", "Zoom")}
</Text>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
onChange={setZoom}
disabled={processing}
/>
<Slider value={zoom} min={1} max={3} step={0.1} onChange={setZoom} disabled={processing} />
</Stack>
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<Button variant="subtle" onClick={onClose} disabled={processing}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={handleSave} loading={processing}>
{processing
? t('config.account.profilePicture.cropper.processing', 'Processing crop...')
: t('config.account.profilePicture.cropper.save', 'Save Cropped Image')}
? t("config.account.profilePicture.cropper.processing", "Processing crop...")
: t("config.account.profilePicture.cropper.save", "Save Cropped Image")}
</Button>
</div>
</Stack>
@@ -40,7 +40,7 @@ export default function ApiKeys() {
};
const goToAccount = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "overview" } }));
};
const showUsage = Boolean(credits);
@@ -48,67 +48,83 @@ export default function ApiKeys() {
return (
<Stack gap={20} p={0}>
{showUsage && (
<UsageSection
<UsageSection
apiUsage={credits!}
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
overlayMessage={t("config.apiKeys.overlayMessage", "Generate a key to see credits and available credits")}
loading={creditsLoading}
/>
)}
{!isAnonymous && apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
{t("config.apiKeys.generateError", "We couldn't generate your API key.")}{" "}
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
{t('common.retry', 'Retry')}
{t("common.retry", "Retry")}
</Anchor>
</Text>
)}
{isAnonymous ? (
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Stack gap={10}>
<Text fw={500}>{t('config.apiKeys.label', 'API Key')}</Text>
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: '1rem' }}>
<Text fw={500}>{t("config.apiKeys.label", "API Key")}</Text>
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: "1rem" }}>
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
{t('config.apiKeys.guestInfo', 'Guest users do not receive API keys. Create an account to get an API key you can use in your applications.')}
{t(
"config.apiKeys.guestInfo",
"Guest users do not receive API keys. Create an account to get an API key you can use in your applications.",
)}
</Text>
<Button size="sm" onClick={goToAccount}>
{t('config.apiKeys.goToAccount', 'Go to Account')}
{t("config.apiKeys.goToAccount", "Go to Account")}
</Button>
</Group>
</Stack>
</Paper>
) : apiKeyLoading ? (
<>
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
{t(
"config.apiKeys.description",
"Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
)}
</Text>
<div
style={{
padding: 18,
borderRadius: 12,
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="center" gap={12} wrap="nowrap">
<SkeletonLoader type="block" width="100%" height={36} />
<SkeletonLoader type="block" width={76} height={32} />
<SkeletonLoader type="block" width={92} height={32} />
</Group>
</div>
</>
) : (
apiKeyLoading ? (
<>
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
{t('config.apiKeys.description', "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.")}
</Text>
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Group align="center" gap={12} wrap="nowrap">
<SkeletonLoader type="block" width="100%" height={36} />
<SkeletonLoader type="block" width={76} height={32} />
<SkeletonLoader type="block" width={92} height={32} />
</Group>
</div>
</>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)}
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
</Stack>
);
}
@@ -1,17 +1,30 @@
import React, { useState } from 'react';
import { Alert, Avatar, Button, Divider, FileButton, Group, Image, LoadingOverlay, PasswordInput, Text, TextInput, Modal } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from '@app/auth/supabase';
import { BASE_PATH } from '@app/constants/app';
import { oauthProviders } from '@app/constants/authProviders';
import { Tooltip } from '@app/components/shared/Tooltip';
import { absoluteWithBasePath } from '@app/constants/app';
import { synchronizeUserUpgrade } from '@app/services/userService';
import { ProfilePictureCropper } from '@app/components/shared/config/ProfilePictureCropper';
import { updateProfilePictureMetadata } from '@app/services/avatarSyncService';
import { deleteCurrentAccount } from '@app/services/accountDeletion';
import { alert as showToast } from '@app/components/toast';
import React, { useState } from "react";
import {
Alert,
Avatar,
Button,
Divider,
FileButton,
Group,
Image,
LoadingOverlay,
PasswordInput,
Text,
TextInput,
Modal,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from "@app/auth/supabase";
import { BASE_PATH } from "@app/constants/app";
import { oauthProviders } from "@app/constants/authProviders";
import { Tooltip } from "@app/components/shared/Tooltip";
import { absoluteWithBasePath } from "@app/constants/app";
import { synchronizeUserUpgrade } from "@app/services/userService";
import { ProfilePictureCropper } from "@app/components/shared/config/ProfilePictureCropper";
import { updateProfilePictureMetadata } from "@app/services/avatarSyncService";
import { deleteCurrentAccount } from "@app/services/accountDeletion";
import { alert as showToast } from "@app/components/toast";
interface OverviewProps {
onLogoutClick: () => void;
@@ -19,12 +32,20 @@ interface OverviewProps {
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const { t } = useTranslation();
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
const {
user,
refreshSession,
signOut,
profilePictureUrl,
profilePictureMetadata,
refreshProfilePicture,
refreshProfilePictureMetadata,
} = useAuth();
const PROFILE_BUCKET = 'profile-pictures';
const PROFILE_BUCKET = "profile-pictures";
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [upgradeError, setUpgradeError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -34,14 +55,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const [cropperOpen, setCropperOpen] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [confirmEmail, setConfirmEmail] = useState('');
const [confirmEmail, setConfirmEmail] = useState("");
const isAnonymous = Boolean(user && isUserAnonymous(user));
const isOAuthPicture = profilePictureMetadata?.source === 'oauth';
const isOAuthPicture = profilePictureMetadata?.source === "oauth";
const provider = profilePictureMetadata?.provider;
const profilePath = user ? `${user.id}/avatar` : null;
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U';
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
const handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
@@ -49,7 +70,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
}
if (file.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
return;
}
@@ -66,7 +87,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
// Validate cropped size (2MB limit)
if (croppedBlob.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
setCropperOpen(false);
setCropperFile(null);
return;
@@ -75,21 +96,18 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.upload(profilePath, croppedBlob, {
upsert: true,
cacheControl: '3600',
contentType: 'image/png'
});
const { error } = await supabase.storage.from(PROFILE_BUCKET).upload(profilePath, croppedBlob, {
upsert: true,
cacheControl: "3600",
contentType: "image/png",
});
if (error) {
setProfileError(error.message || 'Failed to upload profile picture');
setProfileError(error.message || "Failed to upload profile picture");
} else {
// Mark as manual upload in metadata
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
@@ -109,17 +127,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.remove([profilePath]);
const { error } = await supabase.storage.from(PROFILE_BUCKET).remove([profilePath]);
if (error) {
setProfileError(error.message || 'Failed to remove profile picture');
setProfileError(error.message || "Failed to remove profile picture");
} else {
// Clear metadata when removing picture
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
@@ -140,17 +155,19 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
try {
// Update metadata to allow manual uploads
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.'));
setSuccess(
t("config.account.profilePicture.switchedToCustom", "Switched to custom picture. You can now upload your own."),
);
// Clear success message after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (error: unknown) {
setProfileError(error instanceof Error ? error.message : 'Failed to switch to custom picture');
setProfileError(error instanceof Error ? error.message : "Failed to switch to custom picture");
} finally {
setProfileUploading(false);
}
@@ -160,7 +177,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
e.preventDefault();
if (!email.trim()) {
setUpgradeError('Email is required');
setUpgradeError("Email is required");
return;
}
@@ -173,34 +190,34 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
await linkEmailIdentity(email.trim(), password || undefined);
// Synchronize with backend database (using "email" as auth method for email/password)
await synchronizeUserUpgrade('email');
await synchronizeUserUpgrade("email");
// Refresh the session to reflect changes
await refreshSession();
setSuccess('Account upgraded successfully! You can now sign in with your email.');
setEmail('');
setPassword('');
setSuccess("Account upgraded successfully! You can now sign in with your email.");
setEmail("");
setPassword("");
} catch (err: unknown) {
setUpgradeError(err instanceof Error ? err.message : 'Failed to upgrade account');
setUpgradeError(err instanceof Error ? err.message : "Failed to upgrade account");
} finally {
setIsLoading(false);
}
};
const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const handleOAuthUpgrade = async (provider: "github" | "google" | "apple" | "azure") => {
try {
setIsLoading(true);
setUpgradeError(null);
setSuccess(null);
// Store provider info for post-redirect handling
sessionStorage.setItem('pendingUpgrade', 'true');
sessionStorage.setItem('upgradeProvider', provider);
sessionStorage.setItem("pendingUpgrade", "true");
sessionStorage.setItem("upgradeProvider", provider);
// Redirect back to homepage after OAuth completes
// The UseSession hook will handle the pendingUpgrade synchronization
const redirectUrl = absoluteWithBasePath('/auth/callback?next=/');
const redirectUrl = absoluteWithBasePath("/auth/callback?next=/");
const result = await linkOAuthIdentity(provider, redirectUrl);
if (result.data?.url) {
@@ -210,32 +227,31 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
setUpgradeError(errorMessage);
setIsLoading(false);
sessionStorage.removeItem('pendingUpgrade');
sessionStorage.removeItem('upgradeProvider');
sessionStorage.removeItem("pendingUpgrade");
sessionStorage.removeItem("upgradeProvider");
}
};
const handleDeleteAccount = async () => {
if (isAnonymous) return;
try {
setIsDeletingAccount(true);
await deleteCurrentAccount();
setDeleteModalOpen(false);
setConfirmEmail('');
setConfirmEmail("");
await signOut();
window.location.href = absoluteWithBasePath('/login');
window.location.href = absoluteWithBasePath("/login");
} catch (err) {
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
const fallbackMessage = t("config.account.overview.deleteFailed", "Failed to delete account.");
const message = err instanceof Error ? err.message : fallbackMessage;
console.error('[Overview] Delete account failed:', err);
console.error("[Overview] Delete account failed:", err);
showToast({
alertType: 'error',
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
alertType: "error",
title: t("config.account.overview.deleteFailedTitle", "Unable to delete account"),
body: message,
expandable: true,
location: 'top-right',
durationMs: 7000
location: "top-right",
durationMs: 7000,
});
} finally {
setIsDeletingAccount(false);
@@ -244,33 +260,35 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setConfirmEmail('');
setConfirmEmail("");
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem", position: "relative" }}>
<LoadingOverlay visible={isLoading || isDeletingAccount} />
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.overview.title', 'Account Settings')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.overview.title", "Account Settings")}
</h3>
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
<p style={{ margin: "0.25rem 0 0 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{isAnonymous
? t('config.account.overview.guestDescription', 'You are signed in as a guest. Consider upgrading your account below.')
: t('config.account.overview.manageAccountPreferences', 'Manage your account preferences')
}
? t(
"config.account.overview.guestDescription",
"You are signed in as a guest. Consider upgrading your account below.",
)
: t("config.account.overview.manageAccountPreferences", "Manage your account preferences")}
</p>
{user?.email && (
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('config.account.overview.signedInAs', 'Signed in as')}: {user.email}
<p style={{ margin: "0.25rem 0 0 0", color: "var(--mantine-color-dimmed)", fontSize: "0.75rem" }}>
{t("config.account.overview.signedInAs", "Signed in as")}: {user.email}
</p>
)}
</div>
<Button color="red" variant="filled" onClick={onLogoutClick}>
{t('logOut', 'Log out')}
{t("logOut", "Log out")}
</Button>
</div>
</div>
@@ -278,11 +296,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Divider />
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.profilePicture.title', 'Profile picture')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.profilePicture.title", "Profile picture")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.profilePicture.description', 'Upload an image to personalize your account.')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.profilePicture.description", "Upload an image to personalize your account.")}
</p>
{profileError && (
@@ -302,14 +320,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<Text size="sm" c="dimmed">
{t('config.account.profilePicture.usingProvider', 'Using {{provider}} profile picture', {
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'OAuth'
{t("config.account.profilePicture.usingProvider", "Using {{provider}} profile picture", {
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : "OAuth",
})}
</Text>
<Button variant="outline" onClick={handleUseCustomPicture} disabled={profileUploading}>
{t('config.account.profilePicture.useCustom', 'Use custom picture')}
{t("config.account.profilePicture.useCustom", "Use custom picture")}
</Button>
</div>
</Group>
@@ -318,7 +336,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<Group gap="sm">
<FileButton
onChange={handleProfileUpload}
@@ -327,20 +345,16 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
>
{(props) => (
<Button {...props} loading={profileUploading}>
{t('config.account.profilePicture.upload', 'Upload')}
{t("config.account.profilePicture.upload", "Upload")}
</Button>
)}
</FileButton>
<Button
variant="outline"
onClick={handleProfileRemove}
disabled={!profilePictureUrl || profileUploading}
>
{t('config.account.profilePicture.remove', 'Remove')}
<Button variant="outline" onClick={handleProfileRemove} disabled={!profilePictureUrl || profileUploading}>
{t("config.account.profilePicture.remove", "Remove")}
</Button>
</Group>
<Text size="xs" c="var(--mantine-color-dimmed)">
{t('config.account.profilePicture.help', 'PNG, JPG, or WebP up to 2MB.')}
{t("config.account.profilePicture.help", "PNG, JPG, or WebP up to 2MB.")}
</Text>
</div>
</Group>
@@ -362,11 +376,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
{isAnonymous && (
<div>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.upgrade.title', 'Upgrade Guest Account')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.upgrade.title", "Upgrade Guest Account")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.upgrade.description', 'Link your account to preserve your history and access more features!')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.upgrade.description", "Link your account to preserve your history and access more features!")}
</p>
</div>
@@ -382,17 +396,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
</Alert>
)}
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: "1rem" }}>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.socialLogin', 'Upgrade with Social Account')}
{t("config.account.upgrade.socialLogin", "Upgrade with Social Account")}
</Text>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{oauthProviders
.filter(provider => !provider.isDisabled)
.filter((provider) => !provider.isDisabled)
.map((provider) => (
<Tooltip
key={provider.id}
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`}
>
<Button
variant="outline"
@@ -404,7 +418,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
style={{ width: 16, height: 16 }}
/>
}
onClick={() => handleOAuthUpgrade(provider.id as 'github' | 'google' | 'apple' | 'azure')}
onClick={() => handleOAuthUpgrade(provider.id as "github" | "google" | "apple" | "azure")}
disabled={isLoading}
>
{provider.label}
@@ -416,28 +430,28 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<div>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.emailPassword', 'or enter your email & password')}
{t("config.account.upgrade.emailPassword", "or enter your email & password")}
</Text>
<form onSubmit={handleEmailUpgrade}>
<Group align="end" gap="sm">
<TextInput
label={t('config.account.upgrade.email', 'Email')}
placeholder={t('config.account.upgrade.emailPlaceholder', 'Enter your email')}
label={t("config.account.upgrade.email", "Email")}
placeholder={t("config.account.upgrade.emailPlaceholder", "Enter your email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ flex: 1 }}
/>
<PasswordInput
label={t('config.account.upgrade.password', 'Password (optional)')}
placeholder={t('config.account.upgrade.passwordPlaceholder', 'Set a password')}
label={t("config.account.upgrade.password", "Password (optional)")}
placeholder={t("config.account.upgrade.passwordPlaceholder", "Set a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
description={t('config.account.upgrade.passwordNote', 'Leave empty to use email verification only')}
description={t("config.account.upgrade.passwordNote", "Leave empty to use email verification only")}
style={{ flex: 1 }}
/>
<Button type="submit" disabled={isLoading}>
{t('config.account.upgrade.upgradeButton', 'Upgrade Account')}
{t("config.account.upgrade.upgradeButton", "Upgrade Account")}
</Button>
</Group>
</form>
@@ -447,19 +461,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
{/* Delete Account Section */}
{!isAnonymous && (
<div style={{
marginTop: 'auto',
paddingTop: '1.5rem',
display: 'flex',
justifyContent: 'flex-end',
borderTop: '1px solid var(--mantine-color-default-border)'
}}>
<Button
color="red"
variant="outline"
onClick={() => setDeleteModalOpen(true)}
>
{t('config.account.overview.deleteAccount', 'Delete Account')}
<div
style={{
marginTop: "auto",
paddingTop: "1.5rem",
display: "flex",
justifyContent: "flex-end",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Button color="red" variant="outline" onClick={() => setDeleteModalOpen(true)}>
{t("config.account.overview.deleteAccount", "Delete Account")}
</Button>
</div>
)}
@@ -468,34 +480,40 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Modal
opened={deleteModalOpen}
onClose={closeDeleteModal}
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
title={t("config.account.overview.deleteAccountTitle", "Delete Account")}
centered
zIndex={10000}
>
<form onSubmit={(e) => {
e.preventDefault();
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
handleDeleteAccount();
}
}}>
<form
onSubmit={(e) => {
e.preventDefault();
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
handleDeleteAccount();
}
}}
>
<Text size="sm" mb="md">
{t('config.account.overview.deleteWarning',
'This action is permanent and cannot be undone. All your data will be deleted.')}
{t(
"config.account.overview.deleteWarning",
"This action is permanent and cannot be undone. All your data will be deleted.",
)}
</Text>
<Text size="sm" fw={500} mb="xs">
{t('config.account.overview.enterEmailConfirm',
'To confirm deletion, please type your email address ({{email}}) below:',
{ email: user?.email })}
{t(
"config.account.overview.enterEmailConfirm",
"To confirm deletion, please type your email address ({{email}}) below:",
{ email: user?.email },
)}
</Text>
<TextInput
placeholder={user?.email || ''}
placeholder={user?.email || ""}
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
mb="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDeleteModal} type="button">
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
@@ -503,7 +521,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
type="submit"
loading={isDeletingAccount}
>
{t('config.account.overview.confirmDelete', 'Delete My Account')}
{t("config.account.overview.confirmDelete", "Delete My Account")}
</Button>
</Group>
</form>
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
import React, { useState } from "react";
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { supabase } from "@app/auth/supabase";
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
const PasswordSecurity: React.FC = () => {
const { t } = useTranslation();
@@ -11,23 +11,23 @@ const PasswordSecurity: React.FC = () => {
const [opened, setOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [didUpdate, setDidUpdate] = useState(false);
const handleChangePassword = async () => {
if (!newPassword || !confirmPassword) {
setError(t('signup.pleaseFillAllFields', 'Please fill in all fields'));
setError(t("signup.pleaseFillAllFields", "Please fill in all fields"));
return;
}
if (newPassword.length < 6) {
setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long'));
setError(t("signup.passwordTooShort", "Password must be at least 6 characters long"));
return;
}
if (newPassword !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
setError(t("signup.passwordsDoNotMatch", "Passwords do not match"));
return;
}
@@ -43,9 +43,9 @@ const PasswordSecurity: React.FC = () => {
return;
}
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
setNewPassword('');
setConfirmPassword('');
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
setNewPassword("");
setConfirmPassword("");
setDidUpdate(true);
// Replace form with success text, then close after 2s
@@ -56,48 +56,58 @@ const PasswordSecurity: React.FC = () => {
setDidUpdate(false);
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to change password');
setError(e instanceof Error ? e.message : "Failed to change password");
} finally {
setIsLoading(false);
}
};
return (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<LoadingOverlay visible={isLoading} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.security.title', 'Passwords & Security')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.security.description', 'Manage your password and security settings.')}
</p>
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.security.title", "Passwords & Security")}
</h3>
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.security.description", "Manage your password and security settings.")}
</p>
</div>
<Button type="button" onClick={() => setOpened(true)} variant="filled">
{t('config.account.security.changePassword', 'Change password')}
{t("config.account.security.changePassword", "Change password")}
</Button>
</div>
<Modal opened={opened} onClose={() => setOpened(false)} centered title={t('config.account.security.changePassword', 'Change password')} zIndex={Z_INDEX_OVER_SETTINGS_MODAL}>
<Modal
opened={opened}
onClose={() => setOpened(false)}
centered
title={t("config.account.security.changePassword", "Change password")}
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
{error && (
<Alert color="red" mb="md">{error}</Alert>
<Alert color="red" mb="md">
{error}
</Alert>
)}
{didUpdate ? (
<Alert color="green" mb="md">{success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')}</Alert>
<Alert color="green" mb="md">
{success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
</Alert>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<PasswordInput
label={t('account.newPassword', 'New Password')}
placeholder={t('account.newPassword', 'New Password')}
label={t("account.newPassword", "New Password")}
placeholder={t("account.newPassword", "New Password")}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
<PasswordInput
label={t('account.confirmNewPassword', 'Confirm New Password')}
placeholder={t('account.confirmNewPassword', 'Confirm New Password')}
label={t("account.confirmNewPassword", "Confirm New Password")}
placeholder={t("account.confirmNewPassword", "Confirm New Password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
@@ -105,10 +115,10 @@ const PasswordSecurity: React.FC = () => {
<Divider my="sm" />
<Group justify="flex-end">
<Button type="button" variant="default" onClick={() => setOpened(false)}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
{t('config.account.security.update', 'Update password')}
{t("config.account.security.update", "Update password")}
</Button>
</Group>
</div>
@@ -119,5 +129,3 @@ const PasswordSecurity: React.FC = () => {
};
export default PasswordSecurity;
@@ -1,76 +1,85 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core';
import { usePlans, PlanTier } from '@app/hooks/usePlans';
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from '@app/components/shared/StripeCheckoutSaas';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import ApiPackagesSection from '@app/components/shared/config/configSections/plan/ApiPackagesSection';
import ActivePlanSection from '@app/components/shared/config/configSections/plan/ActivePlanSection';
import { useAuth } from '@app/auth/UseSession';
import React, { useState, useCallback, useEffect } from "react";
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
import { usePlans, PlanTier } from "@app/hooks/usePlans";
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from "@app/components/shared/StripeCheckoutSaas";
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
import { useAuth } from "@app/auth/UseSession";
const Plan: React.FC = () => {
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
const [purchaseType, setPurchaseType] = useState<PurchaseType>('subscription');
const [purchaseType, setPurchaseType] = useState<PurchaseType>("subscription");
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
const [currency, setCurrency] = useState<string>('gbp');
const [currency, setCurrency] = useState<string>("gbp");
const { trialStatus } = useAuth();
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
const currencyOptions = [
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
{ value: 'gbp', label: 'British pound (GBP, £)' }
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
{ value: "gbp", label: "British pound (GBP, £)" },
];
const handleUpgradeClick = useCallback((plan: PlanTier) => {
if (!data) return;
const handleUpgradeClick = useCallback(
(plan: PlanTier) => {
if (!data) return;
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open('mailto:[email protected]?subject=Enterprise Plan Inquiry', '_blank');
return;
}
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open("mailto:[email protected]?subject=Enterprise Plan Inquiry", "_blank");
return;
}
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
},
[data],
);
const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => {
if (!data) return;
const handleCreditPurchaseClick = useCallback(
(creditsPack: CreditsPack) => {
if (!data) return;
setSelectedCreditsPack(creditsPack);
setPurchaseType('credits');
setSelectedPlan(null);
setCheckoutOpen(true);
}, [data]);
const handlePaymentSuccess = useCallback((sessionId: string) => {
console.log('Payment successful, session:', sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === 'subscription') {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedCreditsPack(creditsPack);
setPurchaseType("credits");
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
}, [selectedPlan, purchaseType, updateCurrentPlan]);
setCheckoutOpen(true);
},
[data],
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log("Payment successful, session:", sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === "subscription") {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
},
[selectedPlan, purchaseType, updateCurrentPlan],
);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
console.error("Payment error:", error);
// Error is already displayed in the StripeCheckout component
}, []);
@@ -84,11 +93,11 @@ const Plan: React.FC = () => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
const proPlan = Array.from(data.plans.values()).find((plan) => plan.id === "pro");
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType('subscription');
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
@@ -99,12 +108,12 @@ const Plan: React.FC = () => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'add-payment') {
if (params.get("action") === "add-payment") {
handleAddPaymentClick();
// Clean up URL
params.delete('action');
params.delete("action");
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
window.history.replaceState({}, "", newUrl);
}
}, [data, handleAddPaymentClick]);
@@ -137,14 +146,16 @@ const Plan: React.FC = () => {
const plansArray = Array.from(plans.values());
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>Currency</Text>
<Text size="lg" fw={600}>
Currency
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
onChange={(value) => setCurrency(value || "gbp")}
data={currencyOptions}
searchable
clearable={true}
@@ -163,11 +174,7 @@ const Plan: React.FC = () => {
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<AvailablePlansSection plans={plansArray} currentPlan={currentPlan} onUpgradeClick={handleUpgradeClick} />
<Divider />
@@ -183,16 +190,16 @@ const Plan: React.FC = () => {
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
planId={purchaseType === 'subscription' ? (selectedPlan?.id as PlanID) : null}
creditsPack={purchaseType === 'credits' ? selectedCreditsPack : null}
planId={purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : null}
creditsPack={purchaseType === "credits" ? selectedCreditsPack : null}
planName={
purchaseType === 'subscription'
? selectedPlan?.name || ''
: data?.apiPackages.find(pkg => pkg.id === selectedCreditsPack)?.name || ''
purchaseType === "subscription"
? selectedPlan?.name || ""
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)?.name || ""
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
isTrialConversion={trialStatus?.isTrialing && purchaseType === "subscription"}
/>
</div>
);
@@ -1,13 +1,7 @@
import React from "react";
import {
Paper,
Stack,
Group,
Text,
Divider,
} from "@mantine/core";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
import { FractionData } from '@app/types/charts';
import { FractionData } from "@app/types/charts";
import { ApiCredits as ApiUsage } from "@app/types/credits";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { formatUTC } from "@app/components/shared/utils/date";
@@ -26,7 +20,7 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
const { t } = useTranslation();
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
// Totals for overall usage visualization
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
@@ -35,42 +29,49 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
// Prepare data for the stacked bar chart
const fractions: FractionData[] = [
{
name: t('config.apiKeys.includedCredits', 'Included credits'),
name: t("config.apiKeys.includedCredits", "Included credits"),
numerator: Math.max(0, weeklyUsed),
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-weekly-active)"
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-weekly-active)",
},
{
name: t('config.apiKeys.purchasedCredits', 'Purchased credits'),
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
numerator: Math.max(0, boughtUsed),
denominator: Math.max(0, apiUsage.totalBoughtCredits),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-bought-active)"
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-bought-active)",
},
];
return (
<div style={{ position: "relative" }}>
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Stack gap={12}
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Stack
gap={12}
style={{
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
}}
>
<Group justify="space-between">
<Text fw={500}>
{t('config.apiKeys.creditsRemaining', 'Credits Remaining')}: {loading ? (
<SkeletonLoader type="block" width={40} height={14} />
) : (
totalRemaining
)}
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
{loading ? <SkeletonLoader type="block" width={40} height={14} /> : totalRemaining}
</Text>
</Group>
<StackedBarChart
<StackedBarChart
fractions={fractions}
width={640}
height={22}
@@ -79,12 +80,13 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
loading={Boolean(loading)}
animate={!loading}
animationDurationMs={900}
ariaLabel={t('config.apiKeys.chartAriaLabel', {
ariaLabel={t("config.apiKeys.chartAriaLabel", {
includedUsed: Math.max(0, weeklyUsed),
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
purchasedUsed: Math.max(0, boughtUsed),
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
defaultValue: 'Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}'
defaultValue:
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
})}
/>
@@ -93,18 +95,16 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
<Group justify="space-between" wrap="wrap">
<Group gap="lg">
<Text size="sm" c="dimmed">
{t('config.apiKeys.nextReset', 'Next Reset')}: {loading ? (
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
{loading ? (
<SkeletonLoader type="block" width={120} height={12} />
) : (
formatDate(apiUsage.weeklyResetDate, false)
)}
</Text>
<Text size="sm" c="dimmed">
{t('config.apiKeys.lastApiUse', 'Last API Use')}: {loading ? (
<SkeletonLoader type="block" width={160} height={12} />
) : (
formatDate(apiUsage.lastApiUsage, true)
)}
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
{loading ? <SkeletonLoader type="block" width={160} height={12} /> : formatDate(apiUsage.lastApiUsage, true)}
</Text>
</Group>
</Group>
@@ -130,10 +130,10 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
}}
>
<Text size="sm" c="dimmed">
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
{overlayMessage || t("config.apiKeys.overlayMessage", "Generate a key to see credits and available credits")}
</Text>
</div>
)}
</div>
);
}
}
@@ -66,5 +66,3 @@ export function useApiKey() {
}
export default useApiKey;
@@ -12,25 +12,13 @@ function coerceNumber(value: unknown, fallback = 0): number {
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left
),
weeklyCreditsAllocated: coerceNumber(
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total
),
boughtCreditsRemaining: coerceNumber(
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left
),
totalBoughtCredits: coerceNumber(
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total
),
totalAvailableCredits: coerceNumber(
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total
),
weeklyResetDate:
String(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? ""),
lastApiUsage:
String(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? ""),
weeklyCreditsRemaining: coerceNumber(raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left),
weeklyCreditsAllocated: coerceNumber(raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total),
boughtCreditsRemaining: coerceNumber(raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left),
totalBoughtCredits: coerceNumber(raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total),
totalAvailableCredits: coerceNumber(raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total),
weeklyResetDate: String(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? ""),
lastApiUsage: String(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? ""),
};
}
@@ -49,13 +37,14 @@ export function useCredits() {
const res = await apiClient.get<Record<string, unknown>>("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty = !normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
const isEmpty =
!normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
@@ -75,5 +64,3 @@ export function useCredits() {
}
export default useCredits;
@@ -1,10 +1,10 @@
import React from 'react';
import { Card, Text, Group, Flex, Alert, Button, Badge } from '@mantine/core';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CreditCardIcon from '@mui/icons-material/CreditCard';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import React from "react";
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CreditCardIcon from "@mui/icons-material/CreditCard";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
interface TrialStatus {
isTrialing: boolean;
@@ -27,23 +27,20 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
_activeSince,
_nextBillingDate,
trialStatus,
onAddPaymentClick
onAddPaymentClick,
}) => {
const { t } = useTranslation();
return (
<div>
<Flex justify="space-between" align="center">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.activePlan.title", "Active Plan")}
</h3>
<ManageBillingButton
returnUrl={`${window.location.origin}/account`}
trialStatus={trialStatus}
/>
<ManageBillingButton returnUrl={`${window.location.origin}/account`} trialStatus={trialStatus} />
</Flex>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.activePlan.subtitle", "Your current subscription details")}
</p>
{/* Trial Status Alert */}
@@ -53,22 +50,23 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('plan.trial.title', 'Free Trial Active')}
title={t("plan.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t('plan.trial.daysRemaining', 'Your trial ends in {{days}} days', {
days: trialStatus.daysRemaining
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
days: trialStatus.daysRemaining,
})}
</Text>
<Text size="xs" c="dimmed">
{t('plan.trial.endDate', 'Expires: {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
{t("plan.trial.endDate", "Expires: {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
{trialStatus.hasScheduledSub ? (
<Text size="xs" c="green" fw={500} mt="sm">
{t('plan.trial.subscriptionScheduled', 'Subscription scheduled - starts {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
{" "}
{t("plan.trial.subscriptionScheduled", "Subscription scheduled - starts {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
) : (
@@ -80,7 +78,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
)
)}
@@ -96,7 +94,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t('plan.trial.badge', 'Trial')}
{t("plan.trial.badge", "Trial")}
</Badge>
)}
</Group>
@@ -108,7 +106,8 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}{currentPlan.price}/month
{currentPlan.currency}
{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
@@ -1,7 +1,7 @@
import React from 'react';
import { Button, Card, Text, Stack, Flex, Slider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { CreditsPack } from '@app/components/shared/StripeCheckoutSaas';
import React from "react";
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
interface ApiPackage {
id: string;
@@ -23,17 +23,17 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
onCreditPurchaseClick
onCreditPurchaseClick,
}) => {
const { t } = useTranslation();
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.apiPackages.title', 'API Credit Packages')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.apiPackages.title", "API Credit Packages")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.apiPackages.subtitle', 'Purchase API credits for your applications')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.apiPackages.subtitle", "Purchase API credits for your applications")}
</p>
<Card padding="xl" radius="md" className="mb-4">
@@ -41,7 +41,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.selectCredits', 'Select Credit Amount')}
{t("plan.selectCredits", "Select Credit Amount")}
</Text>
<div className="px-4">
@@ -53,10 +53,10 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
max={3}
step={0.01}
marks={[
{ value: 0, label: '100' },
{ value: 1, label: '500' },
{ value: 2, label: '1K' },
{ value: 3, label: '5K' }
{ value: 0, label: "100" },
{ value: 1, label: "500" },
{ value: 2, label: "1K" },
{ value: 3, label: "5K" },
]}
size="lg"
className="mb-6"
@@ -78,10 +78,11 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
{apiPackages[Math.round(selectedCredits)].currency}
{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t('plan.totalCost', 'Total Cost')}
{t("plan.totalCost", "Total Cost")}
</Text>
</div>
@@ -89,7 +90,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
size="lg"
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
>
{t('plan.purchase', 'Purchase')}
{t("plan.purchase", "Purchase")}
</Button>
</Flex>
</Stack>
@@ -98,4 +99,4 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
);
};
export default ApiPackagesSection;
export default ApiPackagesSection;
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import React, { useState } from "react";
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -16,27 +16,23 @@ interface AvailablePlansSectionProps {
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick
}) => {
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, currentPlan, onUpgradeClick }) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
const isUserProOrAbove = currentPlan?.id === 'pro' || currentPlan?.id === 'enterprise';
const isUserProOrAbove = currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.availablePlans.subtitle", "Choose the plan that fits your needs")}
</p>
<div className="flex h-[20rem] mb-4 " style={{ gap: '1rem', overflowX: 'auto' }}>
{plans.map(plan => (
<div className="flex h-[20rem] mb-4 " style={{ gap: "1rem", overflowX: "auto" }}>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
@@ -48,29 +44,25 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')
}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder className="mt-4">
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">{t('plan.feature.title', 'Feature')}</th>
{plans.map(plan => (
<th className="text-left p-2">{t("plan.feature.title", "Feature")}</th>
{plans.map((plan) => (
<th key={plan.id} className="text-center p-2 min-w-24 relative">
{plan.name}
{plan.popular && (
@@ -78,16 +70,16 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
color="blue"
variant="filled"
style={{
position: 'absolute',
top: '0rem',
right: '-2rem',
fontSize: '0.5rem',
fontWeight: '500',
height: '1rem',
padding: '0 0.1rem'
position: "absolute",
top: "0rem",
right: "-2rem",
fontSize: "0.5rem",
fontWeight: "500",
height: "1rem",
padding: "0 0.1rem",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
@@ -97,13 +89,13 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr key={featureIndex} className="border-b">
<td className="p-2">
{plans[0].features[featureIndex].name}
</td>
{plans.map(plan => (
<td className="p-2">{plans[0].features[featureIndex].name}</td>
{plans.map((plan) => (
<td key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}></Text>
<Text c="green" fw={600}>
</Text>
) : (
<Text c="gray">-</Text>
)}
@@ -1,17 +1,17 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
interface PlanCardProps {
plan?: PlanTier;
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: unknown;
currentTier?: string | null; // Accept null for proprietary compatibility
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
loginEnabled?: boolean;
@@ -28,7 +28,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled
loginEnabled: _loginEnabled,
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
@@ -36,26 +36,24 @@ const PlanCard: React.FC<PlanCardProps> = ({
if (!plan) return null; // Safety check
const shouldHideUpgrade = plan.id === 'free' && isUserProOrAbove;
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
return (
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
{plan.popular && (
<Badge
variant="filled"
size='xs'
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
<Badge variant="filled" size="xs" style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="md" className="h-full">
<div>
<Text size="lg" fw={600}>{plan.name}</Text>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="2xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
@@ -83,11 +81,10 @@ const PlanCard: React.FC<PlanCardProps> = ({
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
? t("plan.current", "Current Plan")
: plan.isContactOnly
? t('plan.contact', 'Get in Touch')
: t('plan.upgrade', 'Upgrade')
}
? t("plan.contact", "Get in Touch")
: t("plan.upgrade", "Upgrade")}
</Button>
)}
</Stack>
@@ -1,42 +1,42 @@
import React from 'react';
import { type TFunction } from 'i18next';
import React from "react";
import { type TFunction } from "i18next";
import {
createConfigNavSections as createCoreConfigNavSections,
type ConfigNavSection,
} from '@core/components/shared/config/configNavSections';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PasswordSecurity from '@app/components/shared/config/configSections/PasswordSecurity';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import Plan from '@app/components/shared/config/configSections/Plan';
} from "@core/components/shared/config/configNavSections";
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
interface CreateSaasConfigNavSectionsOptions {
isDev?: boolean;
isAnonymous?: boolean;
t: TFunction<'translation', undefined>;
t: TFunction<"translation", undefined>;
}
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
const preferencesIndex = sections.findIndex((section) => section.title === "Preferences");
if (preferencesIndex === -1) {
return [
...sections,
{
title: 'Preferences',
title: "Preferences",
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
key: "general",
label: "General",
icon: "settings-rounded",
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
key: "hotkeys",
label: "Keyboard Shortcuts",
icon: "keyboard-rounded",
component: <HotkeysSection />,
},
],
@@ -48,8 +48,8 @@ function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSectio
}
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const hasDeveloper = sections.some(section =>
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
const hasDeveloper = sections.some((section) =>
section.items.some((item) => item.key === "developer" || item.key === "api-keys"),
);
if (hasDeveloper) {
@@ -59,12 +59,12 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
return [
...sections,
{
title: 'Developer',
title: "Developer",
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
@@ -72,13 +72,8 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
];
}
function appendBillingSection(
sections: ConfigNavSection[],
t: TFunction<'translation', undefined>,
): ConfigNavSection[] {
const hasPlan = sections.some(section =>
section.items.some(item => item.key === 'plan'),
);
function appendBillingSection(sections: ConfigNavSection[], t: TFunction<"translation", undefined>): ConfigNavSection[] {
const hasPlan = sections.some((section) => section.items.some((item) => item.key === "plan"));
if (hasPlan) {
return sections;
@@ -87,12 +82,12 @@ function appendBillingSection(
return [
...sections,
{
title: 'Billing',
title: "Billing",
items: [
{
key: 'plan',
label: t('config.plan', 'Plan'),
icon: 'credit-card',
key: "plan",
label: t("config.plan", "Plan"),
icon: "credit-card",
component: <Plan />,
},
],
@@ -109,18 +104,18 @@ export function createSaasConfigNavSections(
// Create Account section as the first section with Overview and Passwords & Security
const accountSection: ConfigNavSection = {
title: t('config.account.overview.title', 'Account Settings'),
title: t("config.account.overview.title", "Account Settings"),
items: [
{
key: 'overview',
label: t('config.account.overview.label', 'Overview'),
icon: 'account-circle',
key: "overview",
label: t("config.account.overview.label", "Overview"),
icon: "account-circle",
component: <Overview onLogoutClick={onLogoutClick} />,
},
{
key: 'security',
label: 'Passwords & Security',
icon: 'lock',
key: "security",
label: "Passwords & Security",
icon: "lock",
component: <PasswordSecurity />,
},
],
@@ -129,12 +124,10 @@ export function createSaasConfigNavSections(
let sections = [accountSection, ...baseSections];
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
sections = sections.map(section => ({
sections = sections.map((section) => ({
...section,
items: section.items.map(item =>
item.key === 'general'
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
: item
items: section.items.map((item) =>
item.key === "general" ? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> } : item,
),
}));
@@ -146,7 +139,7 @@ export function createSaasConfigNavSections(
}
if (isDev) {
console.debug('[AppConfigModal] SaaS navigation sections', sections);
console.debug("[AppConfigModal] SaaS navigation sections", sections);
}
return sections;
@@ -1,67 +1,67 @@
// Re-export from core for compatibility
// Override VALID_NAV_KEYS to include saas-specific keys
export const VALID_NAV_KEYS = [
'overview',
'password-security',
'preferences',
'notifications',
'connections',
'general',
'people',
'teams',
'security',
'identity',
'plan',
'payments',
'requests',
'developer',
'api-keys',
'hotkeys',
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
'adminLegal',
'adminPremium',
'adminFeatures',
'adminPlan',
'adminAudit',
'adminUsage',
'adminEndpoints'
"overview",
"password-security",
"preferences",
"notifications",
"connections",
"general",
"people",
"teams",
"security",
"identity",
"plan",
"payments",
"requests",
"developer",
"api-keys",
"hotkeys",
"adminGeneral",
"adminSecurity",
"adminConnections",
"adminPrivacy",
"adminDatabase",
"adminAdvanced",
"adminLegal",
"adminPremium",
"adminFeatures",
"adminPlan",
"adminAudit",
"adminUsage",
"adminEndpoints",
] as const;
// Extend NavKey to include saas-specific keys
export type NavKey =
| 'overview'
| 'password-security'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminPlan'
| 'adminAudit'
| 'adminUsage'
| 'adminEndpoints';
| "overview"
| "password-security"
| "preferences"
| "notifications"
| "connections"
| "general"
| "people"
| "teams"
| "security"
| "identity"
| "plan"
| "payments"
| "requests"
| "developer"
| "api-keys"
| "hotkeys"
| "adminGeneral"
| "adminSecurity"
| "adminConnections"
| "adminPrivacy"
| "adminDatabase"
| "adminAdvanced"
| "adminLegal"
| "adminPremium"
| "adminFeatures"
| "adminPlan"
| "adminAudit"
| "adminUsage"
| "adminEndpoints";
// some of these are not used yet, but appear in figma designs
// some of these are not used yet, but appear in figma designs
@@ -10,5 +10,3 @@ export function formatUTC(iso: string, withTime: boolean): string {
}).format(date);
return withTime ? `${formatted} UTC` : formatted;
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
// Re-export all constants from core
export * from '@core/constants/app';
export * from "@core/constants/app";
// SaaS-specific overrides
// Get base URL with fallback (for use outside React components)
+9 -9
View File
@@ -1,13 +1,13 @@
export type OAuthProviderId = 'google' | 'apple' | 'github' | 'azure'
export type OAuthProviderId = "google" | "apple" | "github" | "azure";
export type OAuthProviderMeta = {
id: OAuthProviderId
label: string
file: string
isDisabled: boolean
}
id: OAuthProviderId;
label: string;
file: string;
isDisabled: boolean;
};
export const oauthProviders: readonly OAuthProviderMeta[] = [
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
] as const
{ id: "google", label: "Google", file: "google.svg", isDisabled: false },
{ id: "github", label: "GitHub", file: "github.svg", isDisabled: false },
] as const;
+74 -63
View File
@@ -1,129 +1,140 @@
import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { signInAnonymously, supabase } from '@app/auth/supabase'
import { isAuthRoute, isHomeRoute, isToolRoute } from '@app/utils/pathUtils'
import { useState, useEffect, useCallback } from "react";
import { useLocation } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { signInAnonymously, supabase } from "@app/auth/supabase";
import { isAuthRoute, isHomeRoute, isToolRoute } from "@app/utils/pathUtils";
interface AutoAnonymousAuthState {
isAutoAuthenticating: boolean
autoAuthError: string | null
shouldTriggerAutoAuth: boolean
isAutoAuthenticating: boolean;
autoAuthError: string | null;
shouldTriggerAutoAuth: boolean;
}
/**
* Automatically signs in users anonymously on direct tool access when not authenticated.
*/
export function useAutoAnonymousAuth() {
const { session, loading } = useAuth()
const location = useLocation()
const { session, loading } = useAuth();
const location = useLocation();
const [state, setState] = useState<AutoAnonymousAuthState>({
isAutoAuthenticating: false,
autoAuthError: null,
shouldTriggerAutoAuth: false
})
shouldTriggerAutoAuth: false,
});
const shouldAutoAuthenticate = useCallback((): boolean => {
const currentPath = location.pathname
if (isAuthRoute(currentPath)) return false
if (isHomeRoute(currentPath)) return false
return isToolRoute(currentPath)
}, [location.pathname])
const currentPath = location.pathname;
if (isAuthRoute(currentPath)) return false;
if (isHomeRoute(currentPath)) return false;
return isToolRoute(currentPath);
}, [location.pathname]);
const waitForToken = useCallback(async (timeoutMs = 7000) => {
// Prefer event-driven approach; fallback to polling if needed
let resolved = false
let resolved = false;
const done = (ok: boolean) => {
resolved = true
return ok
}
resolved = true;
return ok;
};
const hasToken = async () => {
const { data: { session } } = await supabase.auth.getSession()
return !!session?.access_token
}
const {
data: { session },
} = await supabase.auth.getSession();
return !!session?.access_token;
};
if (await hasToken()) return true
if (await hasToken()) return true;
const unsub = supabase.auth.onAuthStateChange(async (_evt, _session) => {
if (!resolved && (await hasToken())) {
unsub.data.subscription.unsubscribe()
done(true)
unsub.data.subscription.unsubscribe();
done(true);
}
})
});
const started = Date.now()
const started = Date.now();
while (!resolved && Date.now() - started < timeoutMs) {
if (await hasToken()) {
unsub.data.subscription.unsubscribe()
return done(true)
unsub.data.subscription.unsubscribe();
return done(true);
}
// gentle backoff
await new Promise(r => setTimeout(r, 120))
await new Promise((r) => setTimeout(r, 120));
}
try { unsub.data.subscription.unsubscribe() } catch (err) {
try {
unsub.data.subscription.unsubscribe();
} catch (err) {
// Ignore unsubscribe errors during cleanup
console.debug('[useAutoAnonymousAuth] Unsubscribe cleanup error:', err)
console.debug("[useAutoAnonymousAuth] Unsubscribe cleanup error:", err);
}
return done(false)
}, [])
return done(false);
}, []);
const triggerAnonymousAuth = useCallback(async () => {
if (state.isAutoAuthenticating) return
if (state.isAutoAuthenticating) return;
setState(prev => ({ ...prev, isAutoAuthenticating: true, autoAuthError: null }))
setState((prev) => ({ ...prev, isAutoAuthenticating: true, autoAuthError: null }));
try {
console.log('[useAutoAnonymousAuth] anonymous auth starting')
console.log("[useAutoAnonymousAuth] anonymous auth starting");
const { error } = await signInAnonymously()
if (error) throw error
const { error } = await signInAnonymously();
if (error) throw error;
// Wait for a usable token so first API calls won't 401/redirect
const ok = await waitForToken(7000)
const ok = await waitForToken(7000);
if (!ok) {
throw new Error('Timed out waiting for anonymous session token')
throw new Error("Timed out waiting for anonymous session token");
}
console.log('[useAutoAnonymousAuth] anonymous auth complete')
setState(prev => ({ ...prev, isAutoAuthenticating: false, shouldTriggerAutoAuth: false }))
console.log("[useAutoAnonymousAuth] anonymous auth complete");
setState((prev) => ({ ...prev, isAutoAuthenticating: false, shouldTriggerAutoAuth: false }));
} catch (e) {
console.error('[useAutoAnonymousAuth] anonymous auth failed', e)
setState(prev => ({
console.error("[useAutoAnonymousAuth] anonymous auth failed", e);
setState((prev) => ({
...prev,
isAutoAuthenticating: false,
autoAuthError: e instanceof Error ? e.message : 'Anonymous authentication failed'
}))
autoAuthError: e instanceof Error ? e.message : "Anonymous authentication failed",
}));
}
}, [state.isAutoAuthenticating, waitForToken])
}, [state.isAutoAuthenticating, waitForToken]);
// Decide whether to auto-auth on mount & whenever location/auth changes
useEffect(() => {
if (loading) return
if (session) return
if (state.isAutoAuthenticating) return
if (loading) return;
if (session) return;
if (state.isAutoAuthenticating) return;
const shouldAuth = shouldAutoAuthenticate()
const shouldAuth = shouldAutoAuthenticate();
if (state.shouldTriggerAutoAuth !== shouldAuth) {
setState(prev => ({ ...prev, shouldTriggerAutoAuth: shouldAuth }))
setState((prev) => ({ ...prev, shouldTriggerAutoAuth: shouldAuth }));
}
if (shouldAuth) {
console.log('[useAutoAnonymousAuth] tool route detected, auto-auth')
triggerAnonymousAuth()
console.log("[useAutoAnonymousAuth] tool route detected, auto-auth");
triggerAnonymousAuth();
}
}, [loading, session, state.isAutoAuthenticating, state.shouldTriggerAutoAuth, shouldAutoAuthenticate, triggerAnonymousAuth])
}, [
loading,
session,
state.isAutoAuthenticating,
state.shouldTriggerAutoAuth,
shouldAutoAuthenticate,
triggerAnonymousAuth,
]);
// Clear error if route is no longer a tool route, or once authenticated
useEffect(() => {
if (session || !shouldAutoAuthenticate()) {
setState(prev => ({ ...prev, autoAuthError: null, shouldTriggerAutoAuth: false }))
setState((prev) => ({ ...prev, autoAuthError: null, shouldTriggerAutoAuth: false }));
}
}, [session, shouldAutoAuthenticate])
}, [session, shouldAutoAuthenticate]);
return {
isAutoAuthenticating: state.isAutoAuthenticating,
autoAuthError: state.autoAuthError,
shouldTriggerAutoAuth: state.shouldTriggerAutoAuth,
triggerAnonymousAuth
}
}
triggerAnonymousAuth,
};
}
@@ -1,5 +1,5 @@
import { Avatar } from '@mantine/core';
import { useAuth } from '@app/auth/UseSession';
import { Avatar } from "@mantine/core";
import { useAuth } from "@app/auth/UseSession";
export function useConfigButtonIcon(): React.ReactNode {
const { profilePictureUrl } = useAuth();
+36 -29
View File
@@ -1,41 +1,48 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useCredits } from '@app/hooks/useCredits';
import { getToolCreditCost } from '@app/utils/creditCosts';
import { openPlanSettings } from '@app/utils/appSettings';
import type { ToolId } from '@app/types/toolId';
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useCredits } from "@app/hooks/useCredits";
import { getToolCreditCost } from "@app/utils/creditCosts";
import { openPlanSettings } from "@app/utils/appSettings";
import type { ToolId } from "@app/types/toolId";
export function useCreditCheck(operationType?: string, _endpoint?: string) {
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } = useCredits();
const { t } = useTranslation();
const checkCredits = useCallback(async (_runtimeEndpoint?: string): Promise<string | null> => {
const requiredCredits = getToolCreditCost(operationType as ToolId);
const creditCheck = hasSufficientCredits(requiredCredits);
const checkCredits = useCallback(
async (_runtimeEndpoint?: string): Promise<string | null> => {
const requiredCredits = getToolCreditCost(operationType as ToolId);
const creditCheck = hasSufficientCredits(requiredCredits);
if (creditBalance === null) {
try { await refreshCredits(); } catch (_e) { void _e; }
return t('loadingCredits', 'Checking credits...');
}
if (creditBalance === null) {
try {
await refreshCredits();
} catch (_e) {
void _e;
}
return t("loadingCredits", "Checking credits...");
}
if (isPro === null) {
return t('loadingProStatus', 'Checking subscription status...');
}
if (isPro === null) {
return t("loadingProStatus", "Checking subscription status...");
}
if (!isPro && !creditCheck.hasSufficientCredits) {
const shortfall = creditCheck.shortfall || 0;
const error = t(
'insufficientCredits',
'Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}',
{ requiredCredits, currentBalance: creditCheck.currentBalance, shortfall },
);
const notice = t('noticeTopUpOrPlan', 'Not enough credits, please top up or upgrade to a plan');
openPlanSettings(notice);
return error;
}
if (!isPro && !creditCheck.hasSufficientCredits) {
const shortfall = creditCheck.shortfall || 0;
const error = t(
"insufficientCredits",
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
{ requiredCredits, currentBalance: creditCheck.currentBalance, shortfall },
);
const notice = t("noticeTopUpOrPlan", "Not enough credits, please top up or upgrade to a plan");
openPlanSettings(notice);
return error;
}
return null;
}, [hasSufficientCredits, isPro, creditBalance, refreshCredits, operationType, t]);
return null;
},
[hasSufficientCredits, isPro, creditBalance, refreshCredits, operationType, t],
);
return { checkCredits };
}
+9 -18
View File
@@ -1,33 +1,24 @@
import { useAuth } from '@app/auth/UseSession'
import { useAuth } from "@app/auth/UseSession";
/**
* Hook for credit management and checking in tools.
* Provides easy access to credit balance, subscription info, and validation functions.
*/
export const useCredits = () => {
const {
creditBalance,
subscription,
creditSummary,
isPro,
hasSufficientCredits,
updateCredits,
refreshCredits
} = useAuth()
const { creditBalance, subscription, creditSummary, isPro, hasSufficientCredits, updateCredits, refreshCredits } = useAuth();
/**
* Get user-friendly credit status message
*/
const getCreditStatusMessage = (): string => {
if (creditBalance === 0) {
return 'No credits remaining'
return "No credits remaining";
}
if (creditBalance === null) {
return 'Credits loading...'
return "Credits loading...";
}
return `${creditBalance} credits available`
}
return `${creditBalance} credits available`;
};
return {
// State
@@ -42,6 +33,6 @@ export const useCredits = () => {
// Utilities
getCreditStatusMessage,
hasSufficientCredits
}
}
hasSufficientCredits,
};
};
+31 -27
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import apiClient from "@app/services/apiClient";
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
@@ -31,12 +31,14 @@ export function useEndpointEnabled(endpoint: string): {
setLoading(true);
setError(null);
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
const response = await apiClient.get<boolean>(
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
);
const isEnabled = response.data;
setEnabled(isEnabled);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
setError(errorMessage);
} finally {
setLoading(false);
@@ -71,11 +73,11 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAllEndpointStatuses = async (force = false) => {
const endpointsKey = [...endpoints].sort().join(',');
const endpointsKey = [...endpoints].sort().join(",");
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
console.debug("[useEndpointConfig] Already fetched these endpoints globally, using cache");
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
@@ -85,10 +87,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
setLoading(false);
return;
}
@@ -104,9 +106,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setError(null);
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
const newEndpoints = endpoints.filter((ep) => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
console.debug("[useEndpointConfig] All endpoints already in global cache");
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
@@ -116,18 +118,20 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
}
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const endpointsParam = newEndpoints.join(",");
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
);
const statusMap = response.data;
// Update global cache with new results
@@ -149,15 +153,15 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: unknown) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (isAxiosError(err) && err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
console.warn("[useEndpointConfig] 401 error - using optimistic fallback");
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
@@ -166,16 +170,16 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
globalEndpointCache[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details }));
setLoading(false);
return;
}
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
setError(errorMessage);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
console.error("[EndpointConfig] Failed to check multiple endpoints:", err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce(
@@ -183,12 +187,12 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
return acc;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> },
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details }));
} finally {
setLoading(false);
}
@@ -196,7 +200,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => {
fetchAllEndpointStatuses();
}, [endpoints.join(',')]); // Re-run when endpoints array changes
}, [endpoints.join(",")]); // Re-run when endpoints array changes
return {
endpointStatus,
+124 -124
View File
@@ -1,18 +1,18 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { supabase } from '@app/auth/supabase';
import { useAuth } from '@app/auth/UseSession';
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { supabase } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
// Currency mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
'gbp': '£',
'usd': '$',
'eur': '€',
'cny': '¥',
'inr': '₹',
'brl': 'R$',
'idr': 'Rp'
gbp: "£",
usd: "$",
eur: "€",
cny: "¥",
inr: "₹",
brl: "R$",
idr: "Rp",
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
@@ -51,132 +51,133 @@ export interface PlansData {
activeSince?: string;
}
export const usePlans = (currency: string = 'gbp') => {
export const usePlans = (currency: string = "gbp") => {
const { t } = useTranslation();
const { isPro, refreshProStatus } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPlanId, setCurrentPlanId] = useState<string>('free');
const [currentPlanId, setCurrentPlanId] = useState<string>("free");
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { unit_amount: number; currency: string }>>(new Map());
const fetchPricing = async () => {
try {
setLoading(true);
setError(null);
const fetchPricing = async () => {
try {
setLoading(true);
setError(null);
const lookupKeys = ['plan:pro', 'api:xsmall', 'api:small', 'api:medium', 'api:large'];
const lookupKeys = ["plan:pro", "api:xsmall", "api:small", "api:medium", "api:large"];
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, { unit_amount: number; currency: string }>;
missing: string[];
}>('stripe-price-lookup', {
body: { lookup_keys: lookupKeys, currency }
});
if (error) throw error;
if (!data || !data.prices || !data.missing) throw new Error('No pricing data returned');
console.log('Fetched pricing data:', data);
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, { unit_amount: number; currency: string }>;
missing: string[];
}>("stripe-price-lookup", {
body: { lookup_keys: lookupKeys, currency },
});
if (error) throw error;
if (!data || !data.prices || !data.missing) throw new Error("No pricing data returned");
console.log("Fetched pricing data:", data);
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
// map your UI keys to lookup keys (if names differ)
const keyMap: Record<string, string> = {
pro: 'plan:pro',
xsmall: 'api:xsmall',
small: 'api:small',
medium: 'api:medium',
large: 'api:large',
};
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
// map your UI keys to lookup keys (if names differ)
const keyMap: Record<string, string> = {
pro: "plan:pro",
xsmall: "api:xsmall",
small: "api:small",
medium: "api:medium",
large: "api:large",
};
for (const [uiKey, lookupKey] of Object.entries(keyMap)) {
const p = data?.prices?.[lookupKey];
if (p) {
priceMap.set(uiKey, {
unit_amount: p.unit_amount ?? 0,
currency: p.currency
});
for (const [uiKey, lookupKey] of Object.entries(keyMap)) {
const p = data?.prices?.[lookupKey];
if (p) {
priceMap.set(uiKey, {
unit_amount: p.unit_amount ?? 0,
currency: p.currency,
});
}
}
if (data.missing.length) {
console.warn("Missing prices for", data.missing, "in", currency);
// Optionally re-request with a fallback currency (e.g., 'usd')
}
setDynamicPrices(priceMap);
} catch (err) {
console.error("Error fetching pricing:", err);
setError(err instanceof Error ? err.message : "Failed to fetch pricing data");
// continue with static prices if needed
} finally {
setLoading(false);
}
if (data.missing.length) {
console.warn('Missing prices for', data.missing, 'in', currency);
// Optionally re-request with a fallback currency (e.g., 'usd')
}
setDynamicPrices(priceMap);
} catch (err) {
console.error('Error fetching pricing:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pricing data');
// continue with static prices if needed
} finally {
setLoading(false);
}
};
};
// Memoize static plan and package data to prevent recreation on every render
const staticPlansData = useMemo(() => {
const plans: PlanTier[] = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: getCurrencySymbol(currency),
period: t('plan.period.month', '/month'),
period: t("plan.period.month", "/month"),
highlights: [
t('plan.free.highlight1', "Limited Tool Usage Per week"),
t('plan.free.highlight2', "Access to all tools"),
t('plan.free.highlight3', 'Community support')
t("plan.free.highlight1", "Limited Tool Usage Per week"),
t("plan.free.highlight2", "Access to all tools"),
t("plan.free.highlight3", "Community support"),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: false },
{ name: t('plan.feature.api', 'API Access'), included: false },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: false },
{ name: t("plan.feature.automation", "automate tool workflows"), included: false },
{ name: t("plan.feature.api", "API Access"), included: false },
{ name: t("plan.feature.priority", "Priority Support"), included: false },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
],
},
{
id: 'pro',
name: t('plan.pro.name', 'Pro'),
price: dynamicPrices.get('pro') ? dynamicPrices.get('pro')!.unit_amount / 100 : 8,
currency: dynamicPrices.get('pro') ? getCurrencySymbol(dynamicPrices.get('pro')!.currency) : getCurrencySymbol(currency),
period: t('plan.period.month', '/month'),
id: "pro",
name: t("plan.pro.name", "Pro"),
price: dynamicPrices.get("pro") ? dynamicPrices.get("pro")!.unit_amount / 100 : 8,
currency: dynamicPrices.get("pro")
? getCurrencySymbol(dynamicPrices.get("pro")!.currency)
: getCurrencySymbol(currency),
period: t("plan.period.month", "/month"),
popular: true,
highlights: [
t('plan.pro.highlight1', 'Unlimited Tool Usage'),
t('plan.pro.highlight2', 'Advanced PDF tools'),
t('plan.pro.highlight3', 'No watermarks')
t("plan.pro.highlight1", "Unlimited Tool Usage"),
t("plan.pro.highlight2", "Advanced PDF tools"),
t("plan.pro.highlight3", "No watermarks"),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: false },
]
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
{ name: t("plan.feature.automation", "automate tool workflows"), included: true },
{ name: t("plan.feature.api", "Weekly API Credits"), included: true },
{ name: t("plan.feature.priority", "Priority Support"), included: false },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: false },
],
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: getCurrencySymbol(currency),
period: '',
period: "",
isContactOnly: true,
highlights: [
t('plan.enterprise.highlight1', 'Custom pricing'),
t('plan.enterprise.highlight2', 'Dedicated support'),
t('plan.enterprise.highlight3', 'Latest features')
t("plan.enterprise.highlight1", "Custom pricing"),
t("plan.enterprise.highlight2", "Dedicated support"),
t("plan.enterprise.highlight3", "Latest features"),
],
features: [
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
{ name: t('plan.feature.automation', 'automate tool workflows'), included: true },
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
{ name: t('plan.feature.customPricing', 'Custom Pricing'), included: true },
]
}
{ name: t("plan.feature.pdfTools", "Basic PDF Tools"), included: true },
{ name: t("plan.feature.fileSize", "File Size Limit"), included: true },
{ name: t("plan.feature.automation", "automate tool workflows"), included: true },
{ name: t("plan.feature.api", "Weekly API Credits"), included: true },
{ name: t("plan.feature.priority", "Priority Support"), included: true },
{ name: t("plan.feature.customPricing", "Custom Pricing"), included: true },
],
},
];
// Helper function to get price info
@@ -187,10 +188,10 @@ const fetchPricing = async () => {
return { price, currencySymbol };
};
const xsmallPrice = getPriceInfo('xsmall', 4);
const smallPrice = getPriceInfo('small', 15);
const mediumPrice = getPriceInfo('medium', 25);
const largePrice = getPriceInfo('large', 90);
const xsmallPrice = getPriceInfo("xsmall", 4);
const smallPrice = getPriceInfo("small", 15);
const mediumPrice = getPriceInfo("medium", 25);
const largePrice = getPriceInfo("large", 90);
// Calculate dynamic discounts based on per-credit cost (using xsmall as baseline)
const xsmallPerCredit = xsmallPrice.price / 100;
@@ -204,40 +205,40 @@ const fetchPricing = async () => {
const apiPackages: ApiPackage[] = [
{
id: 'xsmall',
name: t('plan.api.xsmall', '100 Credits'),
id: "xsmall",
name: t("plan.api.xsmall", "100 Credits"),
price: xsmallPrice.price,
currency: xsmallPrice.currencySymbol,
credits: 100,
description: `${xsmallPrice.currencySymbol}${(xsmallPrice.price / 100).toFixed(3)} per credit`
description: `${xsmallPrice.currencySymbol}${(xsmallPrice.price / 100).toFixed(3)} per credit`,
},
{
id: 'small',
name: t('plan.api.small', '500 Credits'),
id: "small",
name: t("plan.api.small", "500 Credits"),
price: smallPrice.price,
currency: smallPrice.currencySymbol,
credits: 500,
description: `${smallPrice.currencySymbol}${(smallPrice.price / 500).toFixed(3)} per credit${smallDiscount > 0 ? `${smallDiscount}% discount` : ''}`
description: `${smallPrice.currencySymbol}${(smallPrice.price / 500).toFixed(3)} per credit${smallDiscount > 0 ? `${smallDiscount}% discount` : ""}`,
},
{
id: 'medium',
name: t('plan.api.medium', '1,000 Credits'),
id: "medium",
name: t("plan.api.medium", "1,000 Credits"),
price: mediumPrice.price,
currency: mediumPrice.currencySymbol,
credits: 1000,
description: `${mediumPrice.currencySymbol}${(mediumPrice.price / 1000).toFixed(3)} per credit${mediumDiscount > 0 ? `${mediumDiscount}% discount` : ''}`
description: `${mediumPrice.currencySymbol}${(mediumPrice.price / 1000).toFixed(3)} per credit${mediumDiscount > 0 ? `${mediumDiscount}% discount` : ""}`,
},
{
id: 'large',
name: t('plan.api.large', '5,000 Credits'),
id: "large",
name: t("plan.api.large", "5,000 Credits"),
price: largePrice.price,
currency: largePrice.currencySymbol,
credits: 5000,
description: `${largePrice.currencySymbol}${(largePrice.price / 5000).toFixed(3)} per credit${largeDiscount > 0 ? `${largeDiscount}% discount` : ''}`
}
description: `${largePrice.currencySymbol}${(largePrice.price / 5000).toFixed(3)} per credit${largeDiscount > 0 ? `${largeDiscount}% discount` : ""}`,
},
];
const plansMap = new Map(plans.map(plan => [plan.id, plan]));
const plansMap = new Map(plans.map((plan) => [plan.id, plan]));
return { plans: plansMap, apiPackages };
}, [t, dynamicPrices]);
@@ -252,16 +253,15 @@ const fetchPricing = async () => {
plans: staticPlansData.plans,
apiPackages: staticPlansData.apiPackages,
currentPlan,
nextBillingDate: 'Feb 15, 2025',
activeSince: 'January 2025'
nextBillingDate: "Feb 15, 2025",
activeSince: "January 2025",
};
}, [staticPlansData, currentPlanId]);
// Update currentPlanId when isPro changes
useEffect(() => {
if (isPro !== null) {
setCurrentPlanId(isPro ? 'pro' : 'free');
setCurrentPlanId(isPro ? "pro" : "free");
}
}, [isPro]);
@@ -280,6 +280,6 @@ const fetchPricing = async () => {
loading,
error,
refetch: refreshProStatus, // Refetch pro status from auth context
updateCurrentPlan // Add local plan update function
updateCurrentPlan, // Add local plan update function
};
};
+96 -95
View File
@@ -1,157 +1,164 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '@app/auth/supabase'
import { Button } from '@mantine/core'
import { withBasePath } from '@app/constants/app'
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@app/auth/supabase";
import { Button } from "@mantine/core";
import { withBasePath } from "@app/constants/app";
interface CallbackState {
status: 'processing' | 'success' | 'error'
message: string
details?: Record<string, unknown>
status: "processing" | "success" | "error";
message: string;
details?: Record<string, unknown>;
}
export default function AuthCallback() {
const navigate = useNavigate()
const navigate = useNavigate();
const [state, setState] = useState<CallbackState>({
status: 'processing',
message: 'Processing authentication...'
})
status: "processing",
message: "Processing authentication...",
});
useEffect(() => {
const handleCallback = async () => {
try {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
const errorDescription = url.searchParams.get('error_description')
const next = url.searchParams.get('next') || '/'
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");
const next = url.searchParams.get("next") || "/";
console.log('[Auth Callback Debug] URL parameters:', {
console.log("[Auth Callback Debug] URL parameters:", {
hasCode: !!code,
hasError: !!error,
error,
errorDescription,
next,
fullUrl: window.location.href
})
fullUrl: window.location.href,
});
// Handle OAuth errors
if (error) {
const errorMsg = errorDescription || error
console.error('[Auth Callback Debug] OAuth error:', { error, errorDescription })
const errorMsg = errorDescription || error;
console.error("[Auth Callback Debug] OAuth error:", { error, errorDescription });
setState({
status: 'error',
status: "error",
message: `Authentication failed: ${errorMsg}`,
details: { error, errorDescription }
})
details: { error, errorDescription },
});
// Redirect to login page after 3 seconds
setTimeout(() => navigate('/login', { replace: true }), 3000)
return
setTimeout(() => navigate("/login", { replace: true }), 3000);
return;
}
// If PKCE/SSR-style code is present, exchange it for a session
if (code) {
console.log('[Auth Callback Debug] Exchanging code for session...')
console.log("[Auth Callback Debug] Exchanging code for session...");
setState({
status: 'processing',
message: 'Exchanging authorization code...'
})
status: "processing",
message: "Exchanging authorization code...",
});
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) {
console.error('[Auth Callback Debug] Code exchange error:', exchangeError)
console.error("[Auth Callback Debug] Code exchange error:", exchangeError);
setState({
status: 'error',
status: "error",
message: `Failed to complete sign in: ${exchangeError.message}`,
details: { exchangeError }
})
details: { exchangeError },
});
setTimeout(() => navigate('/login', { replace: true }), 3000)
return
setTimeout(() => navigate("/login", { replace: true }), 3000);
return;
}
console.log('[Auth Callback Debug] Code exchange successful:', {
console.log("[Auth Callback Debug] Code exchange successful:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email
})
email: data.session?.user?.email,
});
setState({
status: 'success',
message: 'Sign in successful! Redirecting...',
status: "success",
message: "Sign in successful! Redirecting...",
details: {
userId: data.session?.user?.id,
email: data.session?.user?.email,
provider: data.session?.user?.app_metadata?.provider
}
})
provider: data.session?.user?.app_metadata?.provider,
},
});
} else {
// No code present - might already be authenticated
console.log('[Auth Callback Debug] No code present, checking existing session...')
console.log("[Auth Callback Debug] No code present, checking existing session...");
const { data: sessionData } = await supabase.auth.getSession()
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData.session) {
console.log('[Auth Callback Debug] Existing session found')
console.log("[Auth Callback Debug] Existing session found");
setState({
status: 'success',
message: 'Already signed in! Redirecting...'
})
status: "success",
message: "Already signed in! Redirecting...",
});
} else {
console.log('[Auth Callback Debug] No session found')
console.log("[Auth Callback Debug] No session found");
setState({
status: 'error',
message: 'No authentication data found'
})
setTimeout(() => navigate('/login', { replace: true }), 2000)
return
status: "error",
message: "No authentication data found",
});
setTimeout(() => navigate("/login", { replace: true }), 2000);
return;
}
}
// Redirect to the intended destination
const destination = next.startsWith('/') ? next : '/'
console.log('[Auth Callback Debug] Redirecting to:', destination)
setTimeout(() => navigate(destination, { replace: true }), 1500)
const destination = next.startsWith("/") ? next : "/";
console.log("[Auth Callback Debug] Redirecting to:", destination);
setTimeout(() => navigate(destination, { replace: true }), 1500);
} catch (err) {
console.error('[Auth Callback Debug] Unexpected error:', err)
console.error("[Auth Callback Debug] Unexpected error:", err);
setState({
status: 'error',
message: `Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`,
details: { error: err }
})
status: "error",
message: `Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
details: { error: err },
});
setTimeout(() => navigate('/login', { replace: true }), 3000)
setTimeout(() => navigate("/login", { replace: true }), 3000);
}
}
};
handleCallback()
}, [navigate])
handleCallback();
}, [navigate]);
const getStatusColor = () => {
switch (state.status) {
case 'processing': return 'text-blue-600'
case 'success': return 'text-green-600'
case 'error': return 'text-red-600'
default: return 'text-gray-600'
case "processing":
return "text-blue-600";
case "success":
return "text-green-600";
case "error":
return "text-red-600";
default:
return "text-gray-600";
}
}
};
const getTitle = () => {
switch (state.status) {
case 'processing': return 'Signing you in'
case 'success': return 'You\'re all set!'
case 'error': return 'Authentication failed'
default: return 'Authentication'
case "processing":
return "Signing you in";
case "success":
return "You're all set!";
case "error":
return "Authentication failed";
default:
return "Authentication";
}
}
};
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
@@ -168,14 +175,10 @@ export default function AuthCallback() {
className="mx-auto mb-5 h-8 opacity-80"
/>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{getTitle()}
</h1>
<p className={`text-base ${getStatusColor()}`}>
{state.message}
</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{getTitle()}</h1>
<p className={`text-base ${getStatusColor()}`}>{state.message}</p>
{state.status === 'processing' && (
{state.status === "processing" && (
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
@@ -187,24 +190,22 @@ export default function AuthCallback() {
{/* Action button - only show if error */}
<div className="mt-6 flex items-center justify-center gap-3">
{(() => {
if (state.status === 'error') {
if (state.status === "error") {
return (
<Button
onClick={() => navigate('/login', { replace: true })}
onClick={() => navigate("/login", { replace: true })}
className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
>
Back to login
</Button>
)
);
}
})()}
</div>
{import.meta.env.DEV && state.details && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Debug Information
</summary>
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">Debug Information</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(state.details, null, 2)}
</pre>
@@ -213,5 +214,5 @@ export default function AuthCallback() {
</div>
</div>
</div>
)
);
}
+26 -29
View File
@@ -1,48 +1,46 @@
import React, { useMemo } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
import { isToolRoute } from '@app/utils/pathUtils'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
import GuestUserBanner from '@app/components/auth/GuestUserBanner'
import { TrialStatusBanner } from '@app/components/shared/TrialStatusBanner'
import React, { useMemo } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
import { isToolRoute } from "@app/utils/pathUtils";
import HomePage from "@app/pages/HomePage";
import Login from "@app/routes/Login";
import GuestUserBanner from "@app/components/auth/GuestUserBanner";
import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner";
export default function Landing() {
const { session, loading } = useAuth()
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth()
const location = useLocation()
const { session, loading } = useAuth();
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth();
const location = useLocation();
// Check if current path is a tool (prevents premature navigation on first render)
const isCurrentPathTool = useMemo(() => isToolRoute(location.pathname), [location.pathname])
const isCurrentPathTool = useMemo(() => isToolRoute(location.pathname), [location.pathname]);
// Match the same guarded bypass used in RequireAuth
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')
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");
console.log('[Landing] State:', {
console.log("[Landing] State:", {
pathname: location.pathname,
loading,
hasSession: !!session,
isAutoAuthenticating,
shouldTriggerAutoAuth,
isCurrentPathTool,
autoAuthError
})
autoAuthError,
});
// Show loading while checking auth, while auto-authenticating, OR while preparing to auto-authenticate
// CRITICAL: Also wait if shouldTriggerAutoAuth is true OR if we're on a tool route (prevents navigation before hook evaluates)
if (loading || isAutoAuthenticating || (!session && (shouldTriggerAutoAuth || isCurrentPathTool) && !autoAuthError)) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
{isAutoAuthenticating ? 'Setting up your session...' : 'Loading...'}
</div>
<div className="text-gray-600">{isAutoAuthenticating ? "Setting up your session..." : "Loading..."}</div>
</div>
</div>
)
);
}
// If we have a session or dev bypass is enabled, show the main app
@@ -53,22 +51,21 @@ export default function Landing() {
<TrialStatusBanner />
<HomePage />
</>
)
);
}
// If auto-authentication failed, navigate to login with error state
if (autoAuthError && shouldTriggerAutoAuth) {
return <Navigate to="/login" replace state={{ autoAuthError, from: location }} />
return <Navigate to="/login" replace state={{ autoAuthError, from: location }} />;
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === ''
const isHome = location.pathname === "/" || location.pathname === "";
if (isHome) {
return <Login />
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />
return <Navigate to="/login" replace state={{ from: location }} />;
}
+120 -136
View File
@@ -1,214 +1,210 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase, signInAnonymously } from '@app/auth/supabase'
import { useAuth } from '@app/auth/UseSession'
import { useTranslation } from '@app/hooks/useTranslation'
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase, signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
// Import login components
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
import MagicLinkForm from '@app/routes/login/MagicLinkForm'
import OAuthButtons from '@app/routes/login/OAuthButtons'
import DividerWithText from '@app/components/shared/DividerWithText'
import LoggedInState from '@app/routes/login/LoggedInState'
import { absoluteWithBasePath, getBaseUrl } from '@app/constants/app'
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
export default function Login() {
const navigate = useNavigate()
const { session, loading, refreshSession } = useAuth()
const { t } = useTranslation()
const [isSigningIn, setIsSigningIn] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showMagicLink, setShowMagicLink] = useState(false)
const [showEmailForm, setShowEmailForm] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [magicLinkEmail, setMagicLinkEmail] = useState('')
const navigate = useNavigate();
const { session, loading, refreshSession } = useAuth();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMagicLink, setShowMagicLink] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [magicLinkEmail, setMagicLinkEmail] = useState("");
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
const url = new URL(window.location.href)
const emailFromQuery = url.searchParams.get('email')
const url = new URL(window.location.href);
const emailFromQuery = url.searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery)
setEmail(emailFromQuery);
}
} catch (_) {
// ignore
}
}, [])
}, []);
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t('login.title', 'Sign in')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
title: `${t("login.title", "Sign in")} - Stirling PDF`,
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogTitle: `${t("login.title", "Sign in")} - Stirling PDF`,
ogDescription: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// Show logged in state if authenticated
if (session && !loading) {
return <LoggedInState />
return <LoggedInState />;
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const signInWithProvider = async (provider: "github" | "google" | "apple" | "azure") => {
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
const redirectTo = absoluteWithBasePath('/auth/callback')
console.log(`[Login] Signing in with ${provider}`)
const redirectTo = absoluteWithBasePath("/auth/callback");
console.log(`[Login] Signing in with ${provider}`);
const oauthOptions: { redirectTo: string; queryParams?: Record<string, string> } = { redirectTo }
if (provider === 'apple') {
oauthOptions.queryParams = { scope: 'email name' }
} else if (provider === 'azure') {
oauthOptions.queryParams = { scope: 'openid profile email' }
const oauthOptions: { redirectTo: string; queryParams?: Record<string, string> } = { redirectTo };
if (provider === "apple") {
oauthOptions.queryParams = { scope: "email name" };
} else if (provider === "azure") {
oauthOptions.queryParams = { scope: "openid profile email" };
} else {
oauthOptions.queryParams = {
access_type: 'offline',
prompt: 'consent',
}
access_type: "offline",
prompt: "consent",
};
}
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: oauthOptions
})
options: oauthOptions,
});
if (error) {
console.error(`[Login] ${provider} error:`, error)
setError(t('login.failedToSignIn', { provider, message: error.message }))
console.error(`[Login] ${provider} error:`, error);
setError(t("login.failedToSignIn", { provider, message: error.message }));
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
console.error(`[Login] Unexpected error:`, err);
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const signInWithEmail = async () => {
if (!email || !password) {
setError(t('login.pleaseEnterBoth'))
return
setError(t("login.pleaseEnterBoth"));
return;
}
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log('[Login] Signing in with email:', email)
console.log("[Login] Signing in with email:", email);
const { data, error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password: password
})
password: password,
});
if (error) {
console.error('[Login] Email sign in error:', error)
setError(error.message)
console.error("[Login] Email sign in error:", error);
setError(error.message);
} else if (data.user) {
console.log('[Login] Email sign in successful')
console.log("[Login] Email sign in successful");
// User will be redirected by the auth state change
}
} catch (err) {
console.error('[Login] Unexpected error]:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
console.error("[Login] Unexpected error]:", err);
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const signInWithMagicLink = async () => {
if (!magicLinkEmail) {
setError(t('login.pleaseEnterEmail'))
return
setError(t("login.pleaseEnterEmail"));
return;
}
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log('[Login] Sending magic link to:', magicLinkEmail)
console.log("[Login] Sending magic link to:", magicLinkEmail);
const { error } = await supabase.auth.signInWithOtp({
email: magicLinkEmail.trim(),
options: {
emailRedirectTo: absoluteWithBasePath('/auth/callback')
}
})
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
},
});
if (error) {
console.error('[Login] Magic link error:', error)
setError(error.message)
console.error("[Login] Magic link error:", error);
setError(error.message);
} else {
setError(null)
alert(t('login.magicLinkSent', { email: magicLinkEmail }))
setMagicLinkEmail('')
setShowMagicLink(false)
setError(null);
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
setMagicLinkEmail("");
setShowMagicLink(false);
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
console.error("[Login] Unexpected error:", err);
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const handleForgotPassword = () => {
navigate('/auth/reset')
}
navigate("/auth/reset");
};
const handleAnonymousSignIn = async () => {
try {
setIsSigningIn(true)
setError(null)
console.log('[Login] Signing in anonymously')
setIsSigningIn(true);
setError(null);
console.log("[Login] Signing in anonymously");
const { data } = await signInAnonymously()
const { data } = await signInAnonymously();
if (data.user) {
console.log('[Login] Anonymous sign in successful, refreshing session...')
console.log("[Login] Anonymous sign in successful, refreshing session...");
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession()
await refreshSession();
console.log('[Login] Session refreshed, user will be redirected by auth state change')
console.log("[Login] Session refreshed, user will be redirected by auth state change");
// User will be redirected by the auth state change after session refresh
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }))
console.error("[Login] Unexpected error:", err);
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t('login.login')} subtitle={t('login.subtitle', 'Sign back in to Stirling PDF')} />
<LoginHeader title={t("login.login")} subtitle={t("login.subtitle", "Sign back in to Stirling PDF")} />
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
/>
<OAuthButtons onProviderClick={signInWithProvider} isSubmitting={isSigningIn} layout="fullwidth" />
{/* Divider between OAuth and Email */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
@@ -218,7 +214,7 @@ export default function Login() {
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Sign in with email')}
{t("login.useEmailInstead", "Sign in with email")}
</button>
</div>
@@ -230,52 +226,40 @@ export default function Login() {
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? t('login.loggingIn') : t('login.login')}
submitButtonText={isSigningIn ? t("login.loggingIn") : t("login.login")}
/>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t('login.forgotPassword', 'Forgot your password?')}
<button type="button" onClick={handleForgotPassword} className="auth-link-black">
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
)}
{/* Divider then Guest */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
label={isSigningIn ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
label={isSigningIn ? t("login.signingIn", "Signing in...") : t("login.signInAnonymously", "Sign in as a Guest")}
/>
<div className="auth-bottom-row">
<button
type="button"
onClick={() => setShowMagicLink(true)}
className="auth-link-black"
>
{t('login.useMagicLink', 'Sign in with magic link')}
<button type="button" onClick={() => setShowMagicLink(true)} className="auth-link-black">
{t("login.useMagicLink", "Sign in with magic link")}
</button>
<button
type="button"
onClick={() => navigate('/signup')}
className="auth-link-black"
>
{t('signup.signUp', 'Sign up')}
<button type="button" onClick={() => navigate("/signup")} className="auth-link-black">
{t("signup.signUp", "Sign up")}
</button>
</div>
{/* Magic link form renders on demand */}
{showMagicLink && (
<div style={{ marginTop: '0.5rem' }}>
<div style={{ marginTop: "0.5rem" }}>
<MagicLinkForm
showMagicLink={showMagicLink}
magicLinkEmail={magicLinkEmail}
@@ -287,5 +271,5 @@ export default function Login() {
</div>
)}
</AuthLayout>
)
);
}
+116 -105
View File
@@ -1,170 +1,176 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import SuccessMessage from '@app/routes/login/SuccessMessage'
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'
import NavigationLink from '@app/routes/login/NavigationLink'
import { supabase } from '@app/auth/supabase'
import { absoluteWithBasePath } from '@app/constants/app'
import { useTranslation } from '@app/hooks/useTranslation'
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import SuccessMessage from "@app/routes/login/SuccessMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import NavigationLink from "@app/routes/login/NavigationLink";
import { supabase } from "@app/auth/supabase";
import { absoluteWithBasePath } from "@app/constants/app";
import { useTranslation } from "@app/hooks/useTranslation";
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isRecovery, setIsRecovery] = useState(false)
const [didUpdate, setDidUpdate] = useState(false)
const { t } = useTranslation();
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isRecovery, setIsRecovery] = useState(false);
const [didUpdate, setDidUpdate] = useState(false);
useEffect(() => {
const url = new URL(window.location.href)
const type = url.searchParams.get('type')
const code = url.searchParams.get('code')
const url = new URL(window.location.href);
const type = url.searchParams.get("type");
const code = url.searchParams.get("code");
// Also parse hash params (Supabase puts tokens & type in the hash)
const hash = url.hash || ''
const hashParams = new URLSearchParams(hash.startsWith('#') ? hash.substring(1) : hash)
const hashType = hashParams.get('type')
const hashError = hashParams.get('error')
const hashErrorDescription = hashParams.get('error_description')
const hash = url.hash || "";
const hashParams = new URLSearchParams(hash.startsWith("#") ? hash.substring(1) : hash);
const hashType = hashParams.get("type");
const hashError = hashParams.get("error");
const hashErrorDescription = hashParams.get("error_description");
if (hashError) {
// Show a human-readable error and fall back to email-entry form
setError(hashErrorDescription || hashError)
setIsRecovery(false)
setError(hashErrorDescription || hashError);
setIsRecovery(false);
}
// Consider either source (query or hash) to decide if we're in recovery mode
const inRecovery = type === 'recovery' || hashType === 'recovery'
setIsRecovery(inRecovery)
const inRecovery = type === "recovery" || hashType === "recovery";
setIsRecovery(inRecovery);
// If a PKCE-style code is present, exchange it for a session immediately
const tryExchange = async () => {
if (code) {
try {
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
setError(error.message)
setIsRecovery(false)
setError(error.message);
setIsRecovery(false);
} else if (data.session) {
setIsRecovery(true)
setIsRecovery(true);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setIsRecovery(false)
setError(e instanceof Error ? e.message : String(e));
setIsRecovery(false);
}
} else {
// If no code, see if Supabase already set the session from hash
const { data } = await supabase.auth.getSession()
const { data } = await supabase.auth.getSession();
if (data.session && inRecovery) {
setIsRecovery(true)
setIsRecovery(true);
}
}
}
void tryExchange()
};
void tryExchange();
// Clear sensitive tokens from the URL hash
if (hash.includes('access_token') || hashError) {
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? '?type=recovery' : ''))
if (hash.includes("access_token") || hashError) {
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? "?type=recovery" : ""));
}
// Listen for Supabase auth state changes to confirm recovery state
const { data: sub } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
setIsRecovery(true)
if (event === "PASSWORD_RECOVERY") {
setIsRecovery(true);
}
})
});
return () => {
sub.subscription.unsubscribe()
}
}, [])
sub.subscription.unsubscribe();
};
}, []);
const handleSendEmail = async () => {
if (!email) {
setError(t('login.pleaseEnterEmail'))
return
setError(t("login.pleaseEnterEmail"));
return;
}
try {
setIsSubmitting(true)
setError(null)
const redirectTo = absoluteWithBasePath('/auth/reset?type=recovery')
setIsSubmitting(true);
setError(null);
const redirectTo = absoluteWithBasePath("/auth/reset?type=recovery");
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo
})
redirectTo,
});
if (error) {
setError(error.message)
setError(error.message);
} else {
setSuccess(t('login.passwordResetSent', { email }))
setSuccess(t("login.passwordResetSent", { email }));
}
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
const handleUpdatePassword = async () => {
if (!password || !confirmPassword) {
setError(t('signup.pleaseFillAllFields'))
return
setError(t("signup.pleaseFillAllFields"));
return;
}
if (password.length < 6) {
setError(t('signup.passwordTooShort'))
return
setError(t("signup.passwordTooShort"));
return;
}
if (password !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch'))
return
setError(t("signup.passwordsDoNotMatch"));
return;
}
try {
setIsSubmitting(true)
setError(null)
const { data, error } = await supabase.auth.updateUser({ password })
setIsSubmitting(true);
setError(null);
const { data, error } = await supabase.auth.updateUser({ password });
if (error) {
setError(error.message)
return
setError(error.message);
return;
}
if (data.user) {
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'))
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
// Clear the form fields
setPassword('')
setConfirmPassword('')
setPassword("");
setConfirmPassword("");
// Show success-only state and then redirect after a short delay
setDidUpdate(true)
setDidUpdate(true);
setTimeout(async () => {
const { data: sessionData } = await supabase.auth.getSession()
const { data: userData } = await supabase.auth.getUser()
const derivedEmail = userData.user?.email || email
const { data: sessionData } = await supabase.auth.getSession();
const { data: userData } = await supabase.auth.getUser();
const derivedEmail = userData.user?.email || email;
if (sessionData.session) {
navigate('/')
navigate("/");
} else {
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : ''
navigate(`/login${query}`)
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : "";
navigate(`/login${query}`);
}
}, 2000)
}, 2000);
}
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
return (
<AuthLayout>
<LoginHeader title={isRecovery ? t('login.resetYourPassword', 'Reset your password') : t('login.forgotPassword', 'Forgot your password?')} />
<LoginHeader
title={
isRecovery ? t("login.resetYourPassword", "Reset your password") : t("login.forgotPassword", "Forgot your password?")
}
/>
{!didUpdate && <SuccessMessage success={success} />}
<ErrorMessage error={error} />
{didUpdate ? (
<>
<SuccessMessage success={success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')} />
<SuccessMessage
success={success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
/>
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
@@ -172,26 +178,30 @@ export default function ResetPassword() {
<>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('signup.password')}</label>
<label htmlFor="password" className="auth-label">
{t("signup.password")}
</label>
<input
id="password"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.enterPassword')}
placeholder={t("signup.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input"
/>
</div>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label>
<label htmlFor="confirmPassword" className="auth-label">
{t("signup.confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.confirmPasswordPlaceholder')}
placeholder={t("signup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input"
@@ -203,11 +213,11 @@ export default function ResetPassword() {
disabled={isSubmitting || !password || !confirmPassword}
className="auth-button"
>
{isSubmitting ? t('login.sending', 'Sending…') : t('login.updatePassword', 'Update password')}
{isSubmitting ? t("login.sending", "Sending…") : t("login.updatePassword", "Update password")}
</button>
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
@@ -215,26 +225,27 @@ export default function ResetPassword() {
<>
<EmailPasswordForm
email={email}
password={''}
password={""}
setEmail={setEmail}
setPassword={() => {}}
onSubmit={handleSendEmail}
isSubmitting={isSubmitting}
submitButtonText={t('login.sendResetLink', 'Send reset link')}
submitButtonText={t("login.sendResetLink", "Send reset link")}
showPasswordField={false}
/>
<p className="text-sm text-gray-500 mt-3">
{t('login.resetHelp', 'Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.')}
{t(
"login.resetHelp",
"Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one.",
)}
</p>
<NavigationLink
onClick={() => navigate('/login')}
text={t('login.backToSignIn', 'Back to sign in')}
onClick={() => navigate("/login")}
text={t("login.backToSignIn", "Back to sign in")}
isDisabled={isSubmitting}
/>
</>
)}
</AuthLayout>
)
);
}
+103 -111
View File
@@ -1,171 +1,167 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { signInAnonymously } from '@app/auth/supabase'
import { useAuth } from '@app/auth/UseSession'
import { useTranslation } from '@app/hooks/useTranslation'
import { useDocumentMeta } from '@app/hooks/useDocumentMeta'
import { getBaseUrl } from '@app/constants/app'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import GuestSignInButton from '@app/routes/authShared/GuestSignInButton'
import { alert } from '@app/components/toast'
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getBaseUrl } from "@app/constants/app";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import { alert } from "@app/components/toast";
// Import signup components
import LoginHeader from '@app/routes/login/LoginHeader'
import ErrorMessage from '@app/routes/login/ErrorMessage'
import OAuthButtons from '@app/routes/login/OAuthButtons'
import DividerWithText from '@app/components/shared/DividerWithText'
import SignupForm from '@app/routes/signup/SignupForm'
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'
import { useAuthService } from '@app/routes/signup/AuthService'
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import { useSignupFormValidation, SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
export default function Signup() {
const navigate = useNavigate()
const location = useLocation()
const { session, loading, refreshSession } = useAuth()
const { t } = useTranslation()
const [isSigningUp, setIsSigningUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showEmailForm, setShowEmailForm] = useState(false)
const [name, setName] = useState(undefined as string | undefined)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [agree, setAgree] = useState(true)
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
const navigate = useNavigate();
const location = useLocation();
const { session, loading, refreshSession } = useAuth();
const { t } = useTranslation();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [name, setName] = useState(undefined as string | undefined);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [agree, setAgree] = useState(true);
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// Check if we were redirected here with an auto-auth error
useEffect(() => {
const state = location.state as { autoAuthError?: string } | null
const state = location.state as { autoAuthError?: string } | null;
if (state?.autoAuthError) {
setError(`Unable to access tool: ${state.autoAuthError}`)
setError(`Unable to access tool: ${state.autoAuthError}`);
}
}, [location.state])
}, [location.state]);
// Redirect back to original tool URL once session appears (after auto-anon completes)
useEffect(() => {
if (!loading && session) {
const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null
const from = state?.from
if (from?.pathname && from.pathname !== '/signup' && from.pathname !== '/login') {
const target = `${from.pathname}${from.search ?? ''}${from.hash ?? ''}`
console.log('[Signup] Session detected, redirecting back to:', target)
navigate(target, { replace: true })
const state = location.state as { from?: { pathname?: string; search?: string; hash?: string } } | null;
const from = state?.from;
if (from?.pathname && from.pathname !== "/signup" && from.pathname !== "/login") {
const target = `${from.pathname}${from.search ?? ""}${from.hash ?? ""}`;
console.log("[Signup] Session detected, redirecting back to:", target);
navigate(target, { replace: true });
}
}
}, [loading, session, location.state, navigate])
}, [loading, session, location.state, navigate]);
const handleAnonymousSignIn = async () => {
try {
setIsSigningUp(true)
setError(null)
setIsSigningUp(true);
setError(null);
console.log('[Signup] Initiating anonymous sign-in...')
const { data } = await signInAnonymously()
console.log("[Signup] Initiating anonymous sign-in...");
const { data } = await signInAnonymously();
if (data.user) {
console.log('[Signup] Anonymous sign-in successful, refreshing session...')
console.log("[Signup] Anonymous sign-in successful, refreshing session...");
// Refresh session to ensure backend endpoints are properly synchronized
await refreshSession()
await refreshSession();
console.log('[Signup] Session refreshed, redirecting to home page')
console.log("[Signup] Session refreshed, redirecting to home page");
// Redirect to home page after successful anonymous login and session refresh
navigate('/')
navigate("/");
}
} catch (err) {
console.error('[Signup] Anonymous sign-in unexpected error:', err)
setError(`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`)
console.error("[Signup] Anonymous sign-in unexpected error:", err);
setError(`Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`);
} finally {
setIsSigningUp(false)
setIsSigningUp(false);
}
}
};
const baseUrl = getBaseUrl();
// Set document meta
useDocumentMeta({
title: `${t('signup.title', 'Create an account')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogTitle: `${t("signup.title", "Create an account")} - Stirling PDF`,
ogDescription: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
const { validateSignupForm } = useSignupFormValidation()
const { signUp, signInWithProvider } = useAuthService()
const { validateSignupForm } = useSignupFormValidation();
const { signUp, signInWithProvider } = useAuthService();
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword, name)
const validation = validateSignupForm(email, password, confirmPassword, name);
if (!validation.isValid) {
setError(validation.error)
setFieldErrors(validation.fieldErrors || {})
return
setError(validation.error);
setFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSigningUp(true)
setError(null)
setFieldErrors({})
setIsSigningUp(true);
setError(null);
setFieldErrors({});
const result = await signUp(email, password, name)
const result = await signUp(email, password, name);
if (result.requiresEmailConfirmation) {
alert({
alertType: 'success',
title: t('signup.checkEmailConfirmation'),
location: 'top-right',
isPersistentPopup: true
})
alertType: "success",
title: t("signup.checkEmailConfirmation"),
location: "top-right",
isPersistentPopup: true,
});
} else {
alert({
alertType: 'success',
title: t('signup.accountCreatedSuccessfully'),
location: 'top-right',
durationMs: 3000
})
setTimeout(() => navigate('/login'), 2000)
alertType: "success",
title: t("signup.accountCreatedSuccessfully"),
location: "top-right",
durationMs: 3000,
});
setTimeout(() => navigate("/login"), 2000);
}
} catch (err) {
console.error('[Signup] Unexpected error:', err)
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
console.error("[Signup] Unexpected error:", err);
setError(err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" }));
} finally {
setIsSigningUp(false)
setIsSigningUp(false);
}
}
};
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const handleProviderSignIn = async (provider: "github" | "google" | "apple" | "azure") => {
try {
setIsSigningUp(true)
setError(null)
await signInWithProvider(provider)
setIsSigningUp(true);
setError(null);
await signInWithProvider(provider);
} catch (err) {
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
setError(err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" }));
} finally {
setIsSigningUp(false)
setIsSigningUp(false);
}
}
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t('signup.title')} subtitle={t('signup.subtitle')} />
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
<ErrorMessage error={error} />
{/* OAuth first */}
<div style={{ marginBottom: '0.5rem' }}>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="fullwidth"
/>
<div style={{ marginBottom: "0.5rem" }}>
<OAuthButtons onProviderClick={handleProviderSignIn} isSubmitting={isSigningUp} layout="fullwidth" />
</div>
{/* Divider between OAuth and Email */}
<div style={{ margin: '0.5rem 0' }}>
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<div style={{ margin: "0.5rem 0" }}>
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
</div>
{/* Use Email Instead button (toggles email form) */}
@@ -176,7 +172,7 @@ export default function Signup() {
onClick={() => setShowEmailForm((v) => !v)}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('signup.useEmailInstead', 'Use Email Instead')}
{t("signup.useEmailInstead", "Use Email Instead")}
</button>
</div>
@@ -199,25 +195,21 @@ export default function Signup() {
)}
<div className="auth-section-sm">
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
</div>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
label={isSigningUp ? t('login.signingIn', 'Signing in...') : t('login.signInAnonymously', 'Sign in as a Guest')}
label={isSigningUp ? t("login.signingIn", "Signing in...") : t("login.signInAnonymously", "Sign in as a Guest")}
/>
{/* Bottom row */}
<div className="auth-bottom-right">
<button
type="button"
onClick={() => navigate('/login')}
className="auth-link-black"
>
{t('login.logIn', 'Log In')}
<button type="button" onClick={() => navigate("/login")} className="auth-link-black">
{t("login.logIn", "Log In")}
</button>
</div>
</AuthLayout>
)
);
}
@@ -1,15 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import Footer from '@app/components/shared/Footer';
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import { useIsOverflowing } from "@app/hooks/useIsOverflowing";
import Footer from "@app/components/shared/Footer";
interface AuthLayoutProps {
children: React.ReactNode
isEmailFormExpanded?: boolean
children: React.ReactNode;
isEmailFormExpanded?: boolean;
}
export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) {
@@ -27,15 +27,15 @@ export default function AuthLayout({ children, isEmailFormExpanded = false }: Au
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
const previousColorScheme = htmlElement.getAttribute("data-mantine-color-scheme");
// Set light mode
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
htmlElement.setAttribute("data-mantine-color-scheme", "light");
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
htmlElement.setAttribute("data-mantine-color-scheme", previousColorScheme);
}
};
}, []);
@@ -52,37 +52,30 @@ export default function AuthLayout({ children, isEmailFormExpanded = false }: Au
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<div className={styles.authMain}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
>
<div ref={cardRef} className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}>
<div
ref={leftPanelRef}
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
>
<div className={styles.authContent}>
{children}
</div>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
)}
{!hideRightPanel && <LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />}
</div>
</div>
<div style={{ width: '100vw', marginTop: 'auto', marginLeft: '-1.5rem', marginRight: '-1.5rem' }}>
<div style={{ width: "100vw", marginTop: "auto", marginLeft: "-1.5rem", marginRight: "-1.5rem" }}>
<Footer forceLightMode={true} analyticsEnabled />
</div>
</div>
)
);
}
@@ -1,11 +1,11 @@
import React from 'react'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import React from "react";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface GuestSignInButtonProps {
label: string
onClick: () => void
disabled?: boolean
label: string;
onClick: () => void;
disabled?: boolean;
}
export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) {
@@ -18,7 +18,5 @@ export default function GuestSignInButton({ label, onClick, disabled }: GuestSig
>
{label}
</button>
)
);
}
@@ -49,7 +49,9 @@
color: #000000;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
}
@@ -1,20 +1,20 @@
import { useTranslation } from 'react-i18next';
import '@app/routes/authShared/auth.css';
import '@app/routes/authShared/saas-auth.css';
import { useTranslation } from "react-i18next";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface EmailPasswordFormProps {
email: string
password: string
setEmail: (email: string) => void
setPassword: (password: string) => void
onSubmit: () => void
isSubmitting: boolean
submitButtonText: string
showPasswordField?: boolean
email: string;
password: string;
setEmail: (email: string) => void;
setPassword: (password: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
submitButtonText: string;
showPasswordField?: boolean;
fieldErrors?: {
email?: string
password?: string
}
email?: string;
password?: string;
};
}
export default function EmailPasswordForm({
@@ -26,7 +26,7 @@ export default function EmailPasswordForm({
isSubmitting,
submitButtonText,
showPasswordField = true,
fieldErrors = {}
fieldErrors = {},
}: EmailPasswordFormProps) {
const { t } = useTranslation();
@@ -38,47 +38,43 @@ export default function EmailPasswordForm({
<form onSubmit={handleSubmit}>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="email" className="auth-label">{t('login.email', 'Email')}</label>
<label htmlFor="email" className="auth-label">
{t("login.email", "Email")}
</label>
<input
id="email"
type="email"
name="email"
autoComplete="username email"
placeholder={t('login.enterEmail', 'Enter email')}
placeholder={t("login.enterEmail", "Enter email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
className={`auth-input ${fieldErrors.email ? "auth-input-error" : ""}`}
/>
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
{fieldErrors.email && <div className="auth-field-error">{fieldErrors.email}</div>}
</div>
{showPasswordField && (
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('login.password')}</label>
<label htmlFor="password" className="auth-label">
{t("login.password")}
</label>
<input
id="password"
type="password"
name="current-password"
autoComplete="current-password"
placeholder={t('login.enterPassword')}
placeholder={t("login.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
className={`auth-input ${fieldErrors.password ? "auth-input-error" : ""}`}
/>
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
{fieldErrors.password && <div className="auth-field-error">{fieldErrors.password}</div>}
</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button"
>
<button type="submit" disabled={isSubmitting || !email || (showPasswordField && !password)} className="auth-button">
{submitButtonText}
</button>
</form>
+15 -13
View File
@@ -1,20 +1,22 @@
import { useTranslation } from '@app/hooks/useTranslation'
import { useTranslation } from "@app/hooks/useTranslation";
export default function LoadingState() {
const { t } = useTranslation()
const { t } = useTranslation();
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<p style={{ color: '#6b7280' }}>{t('loading')}</p>
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f3f4f6",
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "32px", marginBottom: "16px" }}></div>
<p style={{ color: "#6b7280" }}>{t("loading")}</p>
</div>
</div>
)
);
}
@@ -1,14 +1,14 @@
import { useTranslation } from '@app/hooks/useTranslation'
import '@app/routes/authShared/auth.css'
import '@app/routes/authShared/saas-auth.css'
import { useTranslation } from "@app/hooks/useTranslation";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface MagicLinkFormProps {
showMagicLink: boolean
magicLinkEmail: string
setMagicLinkEmail: (email: string) => void
setShowMagicLink: (show: boolean) => void
onSubmit: () => void
isSubmitting: boolean
showMagicLink: boolean;
magicLinkEmail: string;
setMagicLinkEmail: (email: string) => void;
setShowMagicLink: (show: boolean) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export default function MagicLinkForm({
@@ -17,41 +17,39 @@ export default function MagicLinkForm({
setMagicLinkEmail,
setShowMagicLink,
onSubmit,
isSubmitting
isSubmitting,
}: MagicLinkFormProps) {
const { t } = useTranslation()
const { t } = useTranslation();
if (!showMagicLink) {
return (
<div className="auth-toggle-wrapper">
<button
onClick={() => { setShowMagicLink(true) }}
onClick={() => {
setShowMagicLink(true);
}}
disabled={isSubmitting}
className="auth-toggle-link"
>
{t('login.useMagicLink')}
{t("login.useMagicLink")}
</button>
</div>
)
);
}
return (
<div className="auth-magic-row">
<input
type="email"
placeholder={t('login.enterEmailForMagicLink')}
placeholder={t("login.enterEmailForMagicLink")}
value={magicLinkEmail}
onChange={(e) => setMagicLinkEmail(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
className="auth-input"
/>
<button
onClick={onSubmit}
disabled={isSubmitting || !magicLinkEmail}
className="auth-magic-button"
>
{isSubmitting ? t('login.sending') : t('login.sendMagicLink')}
<button onClick={onSubmit} disabled={isSubmitting || !magicLinkEmail} className="auth-magic-button">
{isSubmitting ? t("login.sending") : t("login.sendMagicLink")}
</button>
</div>
)
);
}
+54 -41
View File
@@ -1,90 +1,99 @@
import { oauthProviders } from '@app/constants/authProviders'
import { useTranslation } from '@app/hooks/useTranslation'
import { Tooltip } from '@app/components/shared/Tooltip'
import { withBasePath } from '@app/constants/app'
import { oauthProviders } from "@app/constants/authProviders";
import { useTranslation } from "@app/hooks/useTranslation";
import { Tooltip } from "@app/components/shared/Tooltip";
import { withBasePath } from "@app/constants/app";
// Exports for compatibility with proprietary code
export const DEBUG_SHOW_ALL_PROVIDERS = false;
export const oauthProviderConfig = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
apple: { label: 'Apple', file: 'apple.svg' },
azure: { label: 'Microsoft', file: 'microsoft.svg' },
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
apple: { label: "Apple", file: "apple.svg" },
azure: { label: "Microsoft", file: "microsoft.svg" },
};
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google') => void
isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons' | 'fullwidth'
enabledProviders?: string[] // List of enabled provider IDs from backend
onProviderClick: (provider: "github" | "google") => void;
isSubmitting: boolean;
layout?: "vertical" | "grid" | "icons" | "fullwidth";
enabledProviders?: string[]; // List of enabled provider IDs from backend
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders: _enabledProviders = [] }: OAuthButtonsProps) {
const { t } = useTranslation()
export default function OAuthButtons({
onProviderClick,
isSubmitting,
layout = "vertical",
enabledProviders: _enabledProviders = [],
}: OAuthButtonsProps) {
const { t } = useTranslation();
if (layout === 'icons') {
if (layout === "icons") {
return (
<div className="oauth-container-icons">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
position="top"
>
<Tooltip key={p.id} content={`${t("login.signInWith", "Sign in with")} ${p.label}`} position="top">
<button
onClick={() => onProviderClick(p.id as 'github' | 'google')}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-small ${p.isDisabled ? 'opacity-20' : ''}`}/>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-small ${p.isDisabled ? "opacity-20" : ""}`}
/>
</button>
</Tooltip>
))}
</div>
)
);
}
if (layout === 'grid') {
if (layout === "grid") {
return (
<div className="oauth-container-grid">
{oauthProviders.map((p) => (
<Tooltip
key={p.id}
content={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
position="top"
>
<Tooltip key={p.id} content={`${t("login.signInWith", "Sign in with")} ${p.label}`} position="top">
<button
onClick={() => onProviderClick(p.id as 'github' | 'google')}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`}/>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
</button>
</Tooltip>
))}
</div>
)
);
}
if (layout === 'fullwidth') {
if (layout === "fullwidth") {
return (
<div className="oauth-container-fullwidth">
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-fullwidth"
title={p.label}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-medium ${p.isDisabled ? 'opacity-20' : ''}`} />
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
</button>
))}
</div>
)
);
}
return (
@@ -92,15 +101,19 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
{oauthProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as 'github' | 'google')}
onClick={() => onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-vertical"
title={p.label}
>
<img src={withBasePath(`/Login/${p.file}`)} alt={p.label} className={`oauth-icon-tiny ${p.isDisabled ? 'opacity-20' : ''}`} />
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-tiny ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
</button>
))}
</div>
)
);
}
@@ -1,13 +1,13 @@
interface SuccessMessageProps {
success: string | null
success: string | null;
}
export default function SuccessMessage({ success }: SuccessMessageProps) {
if (!success) return null
if (!success) return null;
return (
<div className="success-message">
<p className="success-message-text">{success}</p>
</div>
)
);
}
+23 -28
View File
@@ -1,54 +1,49 @@
import { supabase } from '@app/auth/supabase'
import { absoluteWithBasePath } from '@app/constants/app'
import { supabase } from "@app/auth/supabase";
import { absoluteWithBasePath } from "@app/constants/app";
export const useAuthService = () => {
const signUp = async (
email: string,
password: string,
name?: string
) => {
console.log('[Signup] Creating account for:', email)
const signUp = async (email: string, password: string, name?: string) => {
console.log("[Signup] Creating account for:", email);
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password: password,
options: {
emailRedirectTo: absoluteWithBasePath('/auth/callback'),
data: { full_name: name }
}
})
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
data: { full_name: name },
},
});
if (error) {
console.error('[Signup] Sign up error:', error)
throw new Error(error.message)
console.error("[Signup] Sign up error:", error);
throw new Error(error.message);
}
if (data.user) {
console.log('[Signup] Sign up successful:', data.user)
console.log("[Signup] Sign up successful:", data.user);
return {
user: data.user,
session: data.session,
requiresEmailConfirmation: data.user && !data.session
}
requiresEmailConfirmation: data.user && !data.session,
};
}
throw new Error('Unknown error occurred during signup')
}
throw new Error("Unknown error occurred during signup");
};
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const signInWithProvider = async (provider: "github" | "google" | "apple" | "azure") => {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: absoluteWithBasePath('/auth/callback') }
})
options: { redirectTo: absoluteWithBasePath("/auth/callback") },
});
if (error) {
throw new Error(error.message)
throw new Error(error.message);
}
}
};
return {
signUp,
signInWithProvider
}
}
signInWithProvider,
};
};
@@ -1,4 +1,4 @@
import { supabase } from '@app/auth/supabase';
import { supabase } from "@app/auth/supabase";
interface DeleteAccountOptions {
notifyUser?: boolean;
@@ -12,7 +12,7 @@ interface DeleteUserResponse {
}
export async function deleteCurrentAccount(options?: DeleteAccountOptions): Promise<void> {
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>('delete-user', {
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>("delete-user", {
body: {
notify_user: options?.notifyUser ?? true,
},
@@ -20,7 +20,7 @@ export async function deleteCurrentAccount(options?: DeleteAccountOptions): Prom
if (error || !data?.success) {
const serverMessage = data?.error;
const errorMessage = serverMessage || error?.message || 'Failed to delete account';
const errorMessage = serverMessage || error?.message || "Failed to delete account";
throw new Error(errorMessage);
}
}
+41 -41
View File
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Session, AuthError } from '@supabase/supabase-js';
import { supabase } from '@app/auth/supabase';
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, AuthError } from "@supabase/supabase-js";
import { supabase } from "@app/auth/supabase";
// Mock supabase
vi.mock('@app/auth/supabase', () => ({
vi.mock("@app/auth/supabase", () => ({
supabase: {
auth: {
getSession: vi.fn(),
@@ -12,21 +12,21 @@ vi.mock('@app/auth/supabase', () => ({
},
}));
describe('apiClient', () => {
describe("apiClient", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset modules to get fresh instance of apiClient
vi.resetModules();
});
it('should add JWT token to request headers when session exists', async () => {
const mockToken = 'test-jwt-token-12345';
it("should add JWT token to request headers when session exists", async () => {
const mockToken = "test-jwt-token-12345";
const mockSession = {
access_token: mockToken,
refresh_token: 'refresh-token',
refresh_token: "refresh-token",
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
token_type: "bearer",
user: { id: "user-123" },
} as unknown as Session;
// Mock getSession to return a session with token
@@ -36,7 +36,7 @@ describe('apiClient', () => {
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
const { default: apiClient } = await import("@app/services/apiClient");
// Create a mock adapter to intercept the request
const mockAdapter = vi.fn((config) => {
@@ -45,7 +45,7 @@ describe('apiClient', () => {
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
statusText: "OK",
headers: {},
config,
});
@@ -55,14 +55,14 @@ describe('apiClient', () => {
apiClient.defaults.adapter = mockAdapter;
// Make a test request
await apiClient.get('/api/v1/test');
await apiClient.get("/api/v1/test");
// Verify the request was made with the token
expect(mockAdapter).toHaveBeenCalled();
expect(supabase.auth.getSession).toHaveBeenCalled();
});
it('should handle requests when no session exists', async () => {
it("should handle requests when no session exists", async () => {
// Mock getSession to return no session
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
@@ -70,7 +70,7 @@ describe('apiClient', () => {
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
const { default: apiClient } = await import("@app/services/apiClient");
// Create a mock adapter to intercept the request
const mockAdapter = vi.fn((config) => {
@@ -79,7 +79,7 @@ describe('apiClient', () => {
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
statusText: "OK",
headers: {},
config,
});
@@ -89,31 +89,31 @@ describe('apiClient', () => {
apiClient.defaults.adapter = mockAdapter;
// Make a test request
await apiClient.get('/api/v1/test');
await apiClient.get("/api/v1/test");
// Verify the request was made without a token
expect(mockAdapter).toHaveBeenCalled();
expect(supabase.auth.getSession).toHaveBeenCalled();
});
it('should refresh token on 401 response', async () => {
const oldToken = 'old-token';
const newToken = 'new-refreshed-token';
it("should refresh token on 401 response", async () => {
const oldToken = "old-token";
const newToken = "new-refreshed-token";
const oldSession = {
access_token: oldToken,
refresh_token: 'refresh-token',
refresh_token: "refresh-token",
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
token_type: "bearer",
user: { id: "user-123" },
} as unknown as Session;
const newSession = {
access_token: newToken,
refresh_token: 'new-refresh-token',
refresh_token: "new-refresh-token",
expires_in: 3600,
token_type: 'bearer',
user: { id: 'user-123' },
token_type: "bearer",
user: { id: "user-123" },
} as unknown as Session;
// Mock initial session for first request
@@ -134,7 +134,7 @@ describe('apiClient', () => {
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
const { default: apiClient } = await import("@app/services/apiClient");
let requestCount = 0;
const mockAdapter = vi.fn((config) => {
@@ -144,8 +144,8 @@ describe('apiClient', () => {
if (requestCount === 1) {
// Verify first request has old token
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
const error = Object.assign(new Error('Unauthorized'), {
response: { status: 401, data: { error: 'Unauthorized' } },
const error = Object.assign(new Error("Unauthorized"), {
response: { status: 401, data: { error: "Unauthorized" } },
config,
});
return Promise.reject(error);
@@ -157,7 +157,7 @@ describe('apiClient', () => {
return Promise.resolve({
data: { success: true },
status: 200,
statusText: 'OK',
statusText: "OK",
headers: {},
config,
});
@@ -167,7 +167,7 @@ describe('apiClient', () => {
apiClient.defaults.adapter = mockAdapter;
// Make a test request that will trigger 401 and retry
const response = await apiClient.get('/api/v1/test');
const response = await apiClient.get("/api/v1/test");
// Verify the token was refreshed and request retried
expect(response.data).toEqual({ success: true });
@@ -176,12 +176,12 @@ describe('apiClient', () => {
expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry
});
it('should handle refresh token failure', async () => {
const oldToken = 'old-token';
it("should handle refresh token failure", async () => {
const oldToken = "old-token";
const oldSession = {
access_token: oldToken,
user: { id: 'user-123' },
user: { id: "user-123" },
};
// Mock initial session
@@ -197,19 +197,19 @@ describe('apiClient', () => {
// Mock refresh to fail
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
data: { user: null, session: null },
error: { name: 'AuthError', message: 'Refresh failed', status: 400, code: 'auth_error' } as unknown as AuthError,
error: { name: "AuthError", message: "Refresh failed", status: 400, code: "auth_error" } as unknown as AuthError,
});
// Import apiClient after mocking
const { default: apiClient } = await import('@app/services/apiClient');
const { default: apiClient } = await import("@app/services/apiClient");
// Mock window.location for redirect test
Object.defineProperty(window, 'location', { writable: true, value: { href: '' } });
Object.defineProperty(window, "location", { writable: true, value: { href: "" } });
const mockAdapter = vi.fn((config) => {
// Always return 401 to trigger refresh
const error = Object.assign(new Error('Unauthorized'), {
response: { status: 401, data: { error: 'Unauthorized' } },
const error = Object.assign(new Error("Unauthorized"), {
response: { status: 401, data: { error: "Unauthorized" } },
config,
});
return Promise.reject(error);
@@ -220,14 +220,14 @@ describe('apiClient', () => {
// Make a test request that will trigger 401
try {
await apiClient.get('/api/v1/test');
await apiClient.get("/api/v1/test");
// Should not reach here
expect(true).toBe(false);
} catch (_) {
// Verify refresh was attempted
expect(supabase.auth.refreshSession).toHaveBeenCalled();
// Verify redirect to login
expect(window.location.href).toBe('/login');
expect(window.location.href).toBe("/login");
}
});
});
+74 -70
View File
@@ -1,8 +1,8 @@
import axios from 'axios';
import { supabase } from '@app/auth/supabase';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { alert } from '@app/components/toast';
import { openPlanSettings } from '@app/utils/appSettings';
import axios from "axios";
import { supabase } from "@app/auth/supabase";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { alert } from "@app/components/toast";
import { openPlanSettings } from "@app/utils/appSettings";
// Global credit update callback - will be set by the AuthProvider
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
@@ -15,16 +15,14 @@ export const setGlobalCreditUpdateCallback = (callback: (credits: number) => voi
// Helper: decode base64url JWT payload safely
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
const parts = token.split(".");
if (parts.length < 2) return null;
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=');
const json = typeof atob !== 'undefined'
? atob(padded)
: Buffer.from(padded, 'base64').toString('binary');
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const json = typeof atob !== "undefined" ? atob(padded) : Buffer.from(padded, "base64").toString("binary");
return JSON.parse(json);
} catch (e) {
console.warn('[API Client] Failed to decode JWT payload:', e);
console.warn("[API Client] Failed to decode JWT payload:", e);
return null;
}
}
@@ -32,21 +30,21 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
// Create axios instance with default config
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
responseType: 'json',
responseType: "json",
});
const LOW_CREDIT_THRESHOLD = 10;
function notifyLowCredits(credits: number) {
const title = 'Credit balance low';
const title = "Credit balance low";
const body = `You have ${credits} credits remaining.`;
alert({
alertType: 'warning',
alertType: "warning",
title,
body,
buttonText: 'Top up',
buttonText: "Top up",
buttonCallback: () => openPlanSettings(),
isPersistentPopup: true,
location: 'bottom-right'
location: "bottom-right",
});
}
// Request interceptor to add JWT token to all requests
@@ -54,76 +52,77 @@ apiClient.interceptors.request.use(
async (config) => {
try {
// Get the current session from Supabase
const { data: { session }, error } = await supabase.auth.getSession();
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
console.error('[API Client] Error getting session:', error);
console.error("[API Client] Error getting session:", error);
}
// If we have a session with an access token, add it to the Authorization header
if (session?.access_token) {
config.headers.Authorization = `Bearer ${session.access_token}`;
const payload = decodeJwtPayload(session.access_token);
const role = (payload?.['role'] as string) || (payload?.['user_role'] as string) || undefined;
const aud = payload?.['aud'] as string | undefined;
const isAnon = role === 'anon' || aud === 'anon';
const role = (payload?.["role"] as string) || (payload?.["user_role"] as string) || undefined;
const aud = payload?.["aud"] as string | undefined;
const isAnon = role === "anon" || aud === "anon";
// Debug logs for visibility during integration
if (import.meta.env.DEV) {
console.debug('[API Client] Added JWT token to request:', config.url);
console.debug('[API Client] JWT payload:', payload);
console.debug('[API Client] Token role:', role, '| aud:', aud, '| isAnon:', isAnon);
console.debug("[API Client] Added JWT token to request:", config.url);
console.debug("[API Client] JWT payload:", payload);
console.debug("[API Client] Token role:", role, "| aud:", aud, "| isAnon:", isAnon);
}
} else {
console.debug('[API Client] No JWT token available for request:', config.url);
console.debug("[API Client] No JWT token available for request:", config.url);
}
} catch (error) {
console.error('[API Client] Error in request interceptor:', error);
console.error("[API Client] Error in request interceptor:", error);
}
return config;
},
(error) => {
return Promise.reject(error);
}
},
);
// List of endpoints that don't require authentication
const publicEndpoints = [
'/api/v1/config/app-config',
'/api/v1/info/status',
'/api/v1/config/public-config',
'/api/v1/config/endpoints-enabled',
"/api/v1/config/app-config",
"/api/v1/info/status",
"/api/v1/config/public-config",
"/api/v1/config/endpoints-enabled",
];
// Response interceptor for handling token refresh and credit updates
apiClient.interceptors.response.use(
(response) => {
// Check for X-Credits-Remaining header and update credits automatically
const creditsRemaining = response.headers['x-credits-remaining'];
const creditsRemaining = response.headers["x-credits-remaining"];
if (creditsRemaining && globalCreditUpdateCallback) {
const credits = parseInt(creditsRemaining, 10);
if (!isNaN(credits) && credits >= 0) {
console.debug('[API Client] Updating credits from response header:', credits, 'for URL:', response.config?.url);
console.debug("[API Client] Updating credits from response header:", credits, "for URL:", response.config?.url);
globalCreditUpdateCallback(credits);
// Show low-credit toast with top-up button when below threshold
if (credits < LOW_CREDIT_THRESHOLD) {
notifyLowCredits(credits);
}
} else {
console.warn('[API Client] Invalid credits value in response header:', creditsRemaining);
console.warn("[API Client] Invalid credits value in response header:", creditsRemaining);
}
}
if (response.config?.url?.includes('/api/v1/credits')) {
console.debug('[API Client] Credits endpoint response headers:', response.headers);
if (response.config?.url?.includes("/api/v1/credits")) {
console.debug("[API Client] Credits endpoint response headers:", response.headers);
}
return response;
},
async (error) => {
const originalRequest = error.config;
const isPublicEndpoint = publicEndpoints.some(endpoint =>
originalRequest.url?.includes(endpoint)
);
const isPublicEndpoint = publicEndpoints.some((endpoint) => originalRequest.url?.includes(endpoint));
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
if (error.response?.status === 401 && !originalRequest._retry && !isPublicEndpoint) {
@@ -131,67 +130,72 @@ apiClient.interceptors.response.use(
try {
// Check if we have a session to refresh
const { data: { session } } = await supabase.auth.getSession();
const {
data: { session },
} = await supabase.auth.getSession();
// Only try to refresh if we actually have a session
if (session) {
const { data: { session: refreshedSession }, error: refreshError } = await supabase.auth.refreshSession();
const {
data: { session: refreshedSession },
error: refreshError,
} = await supabase.auth.refreshSession();
if (refreshError) {
console.error('[API Client] Token refresh failed:', refreshError);
if (refreshError) {
console.error("[API Client] Token refresh failed:", refreshError);
// Only redirect to login for protected endpoints, not public ones
const isPublicEndpoint = originalRequest.url?.includes('/api/v1/config/') ||
originalRequest.url?.includes('/api/v1/info/');
// Only redirect to login for protected endpoints, not public ones
const isPublicEndpoint =
originalRequest.url?.includes("/api/v1/config/") || originalRequest.url?.includes("/api/v1/info/");
if (!isPublicEndpoint) {
// Redirect to login only for protected endpoints
window.location.href = '/login';
if (!isPublicEndpoint) {
// Redirect to login only for protected endpoints
window.location.href = "/login";
}
return Promise.reject(error);
}
return Promise.reject(error);
}
if (refreshedSession?.access_token) {
// Update the Authorization header with the new token
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`;
console.debug('[API Client] Retrying request with refreshed token');
console.debug("[API Client] Retrying request with refreshed token");
// Retry the original request with the new token
return apiClient(originalRequest);
}
} else {
// No session exists, only redirect if not already on login page
console.debug('[API Client] No session to refresh, 401 on protected endpoint');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
console.debug("[API Client] No session to refresh, 401 on protected endpoint");
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
} catch (refreshError) {
console.error('[API Client] Error during token refresh:', refreshError);
console.error("[API Client] Error during token refresh:", refreshError);
}
}
// For public endpoints with 401, just log and continue (don't redirect)
if (isPublicEndpoint && error.response?.status === 401) {
console.debug('[API Client] 401 on public endpoint, continuing without auth:', originalRequest.url);
console.debug("[API Client] 401 on public endpoint, continuing without auth:", originalRequest.url);
}
const status = error.response?.status;
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
const status = error.response?.status;
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
console.error('[API Client] HTTP Error', {
status,
method,
url,
error: error.message,
data: error.response?.data
});
console.error("[API Client] HTTP Error", {
status,
method,
url,
error: error.message,
data: error.response?.data,
});
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
},
);
export default apiClient;
+132 -140
View File
@@ -3,24 +3,24 @@
* Downloads, optimizes, and syncs profile pictures from OAuth providers
*/
import { supabase } from '@app/auth/supabase'
import type { User } from '@supabase/supabase-js'
import { supabase } from "@app/auth/supabase";
import type { User } from "@supabase/supabase-js";
const PROFILE_BUCKET = 'profile-pictures'
const AVATAR_SIZE = 256 // 256x256 pixels
const MAX_AVATAR_SIZE = 500 * 1024 // 500KB max file size after optimization
const SYNC_INTERVAL_DAYS = 7 // Resync every 7 days
const PROFILE_BUCKET = "profile-pictures";
const AVATAR_SIZE = 256; // 256x256 pixels
const MAX_AVATAR_SIZE = 500 * 1024; // 500KB max file size after optimization
const SYNC_INTERVAL_DAYS = 7; // Resync every 7 days
// Client-side cache to prevent repeated sync attempts in same browser session
const sessionSyncCache = new Map<string, { timestamp: number; success: boolean }>()
const sessionSyncCache = new Map<string, { timestamp: number; success: boolean }>();
export interface ProfilePictureMetadata {
user_id: string
source: 'oauth' | 'upload'
provider: 'google' | 'github' | 'apple' | 'azure' | null
last_synced_at: string | null
created_at: string
updated_at: string
user_id: string;
source: "oauth" | "upload";
provider: "google" | "github" | "apple" | "azure" | null;
last_synced_at: string | null;
created_at: string;
updated_at: string;
}
/**
@@ -29,26 +29,26 @@ export interface ProfilePictureMetadata {
* @returns Avatar URL or null if not available
*/
export function getProviderAvatarUrl(user: User): string | null {
const provider = user.app_metadata?.provider
const metadata = user.user_metadata
const provider = user.app_metadata?.provider;
const metadata = user.user_metadata;
if (!provider || !metadata) {
return null
return null;
}
switch (provider) {
case 'google':
case 'azure':
case "google":
case "azure":
// Google and Azure use 'picture' field
return metadata.picture || null
case 'github':
return metadata.picture || null;
case "github":
// GitHub uses 'avatar_url' field
return metadata.avatar_url || null
case 'apple':
return metadata.avatar_url || null;
case "apple":
// Apple doesn't provide profile pictures via OAuth
return null
return null;
default:
return null
return null;
}
}
@@ -62,70 +62,70 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
try {
// 1. Fetch image from provider URL
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit',
})
mode: "cors",
credentials: "omit",
});
if (!response.ok) {
throw new Error(`Failed to download avatar: ${response.status} ${response.statusText}`)
throw new Error(`Failed to download avatar: ${response.status} ${response.statusText}`);
}
const blob = await response.blob()
const blob = await response.blob();
// 2. Create image bitmap
const img = await createImageBitmap(blob)
const img = await createImageBitmap(blob);
// 3. Create canvas and draw scaled image
const canvas = document.createElement('canvas')
canvas.width = AVATAR_SIZE
canvas.height = AVATAR_SIZE
const ctx = canvas.getContext('2d')
const canvas = document.createElement("canvas");
canvas.width = AVATAR_SIZE;
canvas.height = AVATAR_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error('Failed to get canvas context')
throw new Error("Failed to get canvas context");
}
// Draw image scaled to fit (maintains aspect ratio, centered)
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height)
const x = (AVATAR_SIZE - img.width * scale) / 2
const y = (AVATAR_SIZE - img.height * scale) / 2
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height);
const x = (AVATAR_SIZE - img.width * scale) / 2;
const y = (AVATAR_SIZE - img.height * scale) / 2;
ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
// 4. Convert to PNG blob with quality optimization
return new Promise((resolve, reject) => {
canvas.toBlob(
(optimizedBlob) => {
if (!optimizedBlob) {
reject(new Error('Failed to create optimized blob'))
return
reject(new Error("Failed to create optimized blob"));
return;
}
// Check file size
if (optimizedBlob.size > MAX_AVATAR_SIZE) {
console.warn('[Avatar Sync] Optimized avatar exceeds max size:', optimizedBlob.size)
console.warn("[Avatar Sync] Optimized avatar exceeds max size:", optimizedBlob.size);
// Try with lower quality
canvas.toBlob(
(lowerQualityBlob) => {
if (lowerQualityBlob) {
resolve(lowerQualityBlob)
resolve(lowerQualityBlob);
} else {
reject(new Error('Failed to create lower quality blob'))
reject(new Error("Failed to create lower quality blob"));
}
},
'image/png',
0.7
)
"image/png",
0.7,
);
} else {
resolve(optimizedBlob)
resolve(optimizedBlob);
}
},
'image/png',
0.9
)
})
"image/png",
0.9,
);
});
} catch (error) {
console.error('[Avatar Sync] Failed to download and optimize avatar:', error)
throw error
console.error("[Avatar Sync] Failed to download and optimize avatar:", error);
throw error;
}
}
@@ -136,27 +136,25 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
*/
export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise<void> {
try {
const profilePath = `${userId}/avatar`
const profilePath = `${userId}/avatar`;
console.debug('[Avatar Sync] Uploading avatar to storage:', profilePath)
console.debug("[Avatar Sync] Uploading avatar to storage:", profilePath);
// Upload to Supabase Storage (overwrites existing file)
const { error: uploadError } = await supabase.storage
.from(PROFILE_BUCKET)
.upload(profilePath, blob, {
upsert: true, // Overwrite existing file
contentType: 'image/png',
cacheControl: '3600', // Cache for 1 hour
})
const { error: uploadError } = await supabase.storage.from(PROFILE_BUCKET).upload(profilePath, blob, {
upsert: true, // Overwrite existing file
contentType: "image/png",
cacheControl: "3600", // Cache for 1 hour
});
if (uploadError) {
throw uploadError
throw uploadError;
}
console.debug('[Avatar Sync] Avatar uploaded successfully')
console.debug("[Avatar Sync] Avatar uploaded successfully");
} catch (error) {
console.error('[Avatar Sync] Failed to upload avatar to storage:', error)
throw error
console.error("[Avatar Sync] Failed to upload avatar to storage:", error);
throw error;
}
}
@@ -165,30 +163,24 @@ export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise
* @param userId User ID
* @returns Metadata or null if not found
*/
export async function getProfilePictureMetadata(
userId: string
): Promise<ProfilePictureMetadata | null> {
export async function getProfilePictureMetadata(userId: string): Promise<ProfilePictureMetadata | null> {
try {
const { data, error } = await supabase
.from('profile_picture_metadata')
.select('*')
.eq('user_id', userId)
.maybeSingle()
const { data, error } = await supabase.from("profile_picture_metadata").select("*").eq("user_id", userId).maybeSingle();
if (error) {
// If table doesn't exist, that's expected before migration runs
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
console.debug('[Avatar Sync] Metadata table not found - migration may not be applied yet')
return null
if (error.code === "PGRST116" || error.message?.includes("does not exist")) {
console.debug("[Avatar Sync] Metadata table not found - migration may not be applied yet");
return null;
}
console.error('[Avatar Sync] Failed to fetch profile picture metadata:', error)
return null
console.error("[Avatar Sync] Failed to fetch profile picture metadata:", error);
return null;
}
return data
return data;
} catch (error) {
console.error('[Avatar Sync] Unexpected error fetching metadata:', error)
return null
console.error("[Avatar Sync] Unexpected error fetching metadata:", error);
return null;
}
}
@@ -199,32 +191,32 @@ export async function getProfilePictureMetadata(
*/
export async function updateProfilePictureMetadata(
userId: string,
data: Partial<Omit<ProfilePictureMetadata, 'user_id' | 'created_at' | 'updated_at'>>
data: Partial<Omit<ProfilePictureMetadata, "user_id" | "created_at" | "updated_at">>,
): Promise<void> {
try {
const { error } = await supabase.from('profile_picture_metadata').upsert(
const { error } = await supabase.from("profile_picture_metadata").upsert(
{
user_id: userId,
...data,
},
{
onConflict: 'user_id',
}
)
onConflict: "user_id",
},
);
if (error) {
// If table doesn't exist, log but don't crash
if (error.code === 'PGRST116' || error.message?.includes('does not exist')) {
console.warn('[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.')
return // Don't throw, allow feature to work without metadata tracking
if (error.code === "PGRST116" || error.message?.includes("does not exist")) {
console.warn("[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.");
return; // Don't throw, allow feature to work without metadata tracking
}
throw error
throw error;
}
console.debug('[Avatar Sync] Metadata updated successfully')
console.debug("[Avatar Sync] Metadata updated successfully");
} catch (error) {
console.error('[Avatar Sync] Failed to update metadata:', error)
throw error
console.error("[Avatar Sync] Failed to update metadata:", error);
throw error;
}
}
@@ -240,99 +232,99 @@ export async function updateProfilePictureMetadata(
* @returns true if sync was performed, false if skipped
*/
export async function syncOAuthAvatar(user: User): Promise<boolean> {
const cacheKey = user.id
const cacheKey = user.id;
try {
// 0. Check client-side session cache first (prevent repeated attempts)
const cached = sessionSyncCache.get(cacheKey)
const cached = sessionSyncCache.get(cacheKey);
if (cached) {
const minutesSinceLastAttempt = (Date.now() - cached.timestamp) / (1000 * 60)
const minutesSinceLastAttempt = (Date.now() - cached.timestamp) / (1000 * 60);
if (minutesSinceLastAttempt < 60) {
console.debug('[Avatar Sync] Skipping sync - already attempted in this session:', {
console.debug("[Avatar Sync] Skipping sync - already attempted in this session:", {
minutesAgo: minutesSinceLastAttempt.toFixed(1),
lastSuccess: cached.success
})
return cached.success
lastSuccess: cached.success,
});
return cached.success;
}
}
// 1. Check if user is OAuth authenticated
const provider = user.app_metadata?.provider
console.debug('[Avatar Sync] Checking user for sync:', {
const provider = user.app_metadata?.provider;
console.debug("[Avatar Sync] Checking user for sync:", {
provider,
userId: user.id,
email: user.email,
hasUserMetadata: !!user.user_metadata,
userMetadataKeys: user.user_metadata ? Object.keys(user.user_metadata) : []
})
userMetadataKeys: user.user_metadata ? Object.keys(user.user_metadata) : [],
});
if (!provider || !['google', 'github', 'azure'].includes(provider)) {
console.debug('[Avatar Sync] Skipping sync - not an OAuth provider with avatar support')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
if (!provider || !["google", "github", "azure"].includes(provider)) {
console.debug("[Avatar Sync] Skipping sync - not an OAuth provider with avatar support");
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
return false;
}
// 2. Get metadata to check if sync is needed
const metadata = await getProfilePictureMetadata(user.id)
const metadata = await getProfilePictureMetadata(user.id);
// Skip if user has manually uploaded a picture
if (metadata?.source === 'upload') {
console.debug('[Avatar Sync] Skipping sync - user has manual upload')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
if (metadata?.source === "upload") {
console.debug("[Avatar Sync] Skipping sync - user has manual upload");
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
return false;
}
// Skip if synced recently (within SYNC_INTERVAL_DAYS)
if (metadata?.last_synced_at) {
const lastSync = new Date(metadata.last_synced_at)
const daysSinceSync = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24)
const lastSync = new Date(metadata.last_synced_at);
const daysSinceSync = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
console.debug('[Avatar Sync] Skipping sync - synced recently:', {
console.debug("[Avatar Sync] Skipping sync - synced recently:", {
daysSinceSync: daysSinceSync.toFixed(1),
threshold: SYNC_INTERVAL_DAYS,
})
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
return false
});
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true });
return false;
}
}
// 3. Extract provider avatar URL
const avatarUrl = getProviderAvatarUrl(user)
console.debug('[Avatar Sync] Avatar URL extraction:', {
const avatarUrl = getProviderAvatarUrl(user);
console.debug("[Avatar Sync] Avatar URL extraction:", {
provider,
avatarUrl,
hasAvatarUrl: !!avatarUrl
})
hasAvatarUrl: !!avatarUrl,
});
if (!avatarUrl) {
console.debug('[Avatar Sync] No avatar URL available from provider')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
return false
console.debug("[Avatar Sync] No avatar URL available from provider");
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
return false;
}
console.debug('[Avatar Sync] Starting sync for provider:', provider, 'with URL:', avatarUrl)
console.debug("[Avatar Sync] Starting sync for provider:", provider, "with URL:", avatarUrl);
// 4. Download and optimize avatar
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl)
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl);
// 5. Upload to Supabase Storage
await uploadAvatarToStorage(user.id, optimizedBlob)
await uploadAvatarToStorage(user.id, optimizedBlob);
// 6. Update metadata
await updateProfilePictureMetadata(user.id, {
source: 'oauth',
provider: provider as ProfilePictureMetadata['provider'],
source: "oauth",
provider: provider as ProfilePictureMetadata["provider"],
last_synced_at: new Date().toISOString(),
})
});
console.debug('[Avatar Sync] Sync completed successfully')
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true })
return true
console.debug("[Avatar Sync] Sync completed successfully");
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true });
return true;
} catch (error) {
console.error('[Avatar Sync] Failed to sync OAuth avatar:', error)
console.error("[Avatar Sync] Failed to sync OAuth avatar:", error);
// Cache the failure to prevent repeated attempts
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false })
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
// Don't throw - gracefully degrade to existing picture or initials
return false
return false;
}
}
@@ -1,6 +1,6 @@
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures";
export type StorageType = 'backend' | 'localStorage';
export type StorageType = "backend" | "localStorage";
interface SignatureStorageCapabilities {
supportsBackend: boolean;
@@ -17,7 +17,7 @@ interface SignatureStorageCapabilities {
class SignatureStorageService {
private capabilities: SignatureStorageCapabilities | null = null;
private blobUrls: Set<string> = new Set();
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
private readonly STORAGE_KEY = "stirling:saved-signatures:v1";
/**
* Detect capabilities - in SaaS mode, always returns localStorage
@@ -28,10 +28,10 @@ class SignatureStorageService {
}
// SaaS mode always uses localStorage (no backend signature API available)
console.log('[SignatureStorage] SaaS mode - using localStorage (backend not available)');
console.log("[SignatureStorage] SaaS mode - using localStorage (backend not available)");
this.capabilities = {
supportsBackend: false,
storageType: 'localStorage',
storageType: "localStorage",
};
return this.capabilities;
@@ -61,7 +61,7 @@ class SignatureStorageService {
*/
async saveSignature(signature: SavedSignature): Promise<void> {
// Force scope to localStorage for SaaS mode
signature.scope = 'localStorage';
signature.scope = "localStorage";
this._saveToLocalStorage(signature);
}
@@ -88,7 +88,7 @@ class SignatureStorageService {
// Ensure all localStorage signatures have the correct scope
return signatures.map((sig: SavedSignature) => ({
...sig,
scope: 'localStorage' as const,
scope: "localStorage" as const,
}));
} catch {
return [];
@@ -97,7 +97,7 @@ class SignatureStorageService {
private _saveToLocalStorage(signature: SavedSignature): void {
const signatures = this._loadFromLocalStorage();
const index = signatures.findIndex(s => s.id === signature.id);
const index = signatures.findIndex((s) => s.id === signature.id);
if (index >= 0) {
signatures[index] = signature;
@@ -110,13 +110,13 @@ class SignatureStorageService {
private _deleteFromLocalStorage(id: string): void {
const signatures = this._loadFromLocalStorage();
const filtered = signatures.filter(s => s.id !== id);
const filtered = signatures.filter((s) => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
private _updateLabelInLocalStorage(id: string, label: string): void {
const signatures = this._loadFromLocalStorage();
const signature = signatures.find(s => s.id === id);
const signature = signatures.find((s) => s.id === id);
if (signature) {
signature.label = label;
signature.updatedAt = Date.now();
@@ -129,7 +129,7 @@ class SignatureStorageService {
* In SaaS mode, this is a no-op since we don't support backend storage
*/
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
console.log('[SignatureStorage] Migration not supported in SaaS mode');
console.log("[SignatureStorage] Migration not supported in SaaS mode");
return { migrated: 0, failed: 0 };
}
@@ -137,7 +137,7 @@ class SignatureStorageService {
* Clean up blob URLs to prevent memory leaks
*/
cleanup(): void {
this.blobUrls.forEach(url => {
this.blobUrls.forEach((url) => {
URL.revokeObjectURL(url);
});
this.blobUrls.clear();
@@ -1,5 +1,5 @@
import apiClient from '@app/services/apiClient';
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
import apiClient from "@app/services/apiClient";
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
export interface User {
id: number;
@@ -45,7 +45,7 @@ export interface CreateUserRequest {
password?: string;
role: string;
teamId?: number;
authType: 'password' | 'SSO';
authType: "password" | "SSO";
forceChange?: boolean;
}
@@ -107,7 +107,7 @@ export const userManagementService = {
* Get all users with session data (admin only)
*/
async getUsers(): Promise<AdminSettingsData> {
const response = await apiClient.get<AdminSettingsData>('/api/v1/proprietary/ui-data/admin-settings');
const response = await apiClient.get<AdminSettingsData>("/api/v1/proprietary/ui-data/admin-settings");
return response.data;
},
@@ -115,7 +115,7 @@ export const userManagementService = {
* Get users without a team
*/
async getUsersWithoutTeam(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/v1/users/without-team');
const response = await apiClient.get<User[]>("/api/v1/users/without-team");
return response.data;
},
@@ -124,19 +124,19 @@ export const userManagementService = {
*/
async createUser(data: CreateUserRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
formData.append("username", data.username);
if (data.password) {
formData.append('password', data.password);
formData.append("password", data.password);
}
formData.append('role', data.role);
formData.append("role", data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
formData.append("teamId", data.teamId.toString());
}
formData.append('authType', data.authType);
formData.append("authType", data.authType);
if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString());
formData.append("forceChange", data.forceChange.toString());
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
await apiClient.post("/api/v1/user/admin/saveUser", formData, {
suppressErrorToast: true, // Component will handle error display
});
},
@@ -146,12 +146,12 @@ export const userManagementService = {
*/
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
formData.append('role', data.role);
formData.append("username", data.username);
formData.append("role", data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
formData.append("teamId", data.teamId.toString());
}
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
await apiClient.post("/api/v1/user/admin/changeRole", formData, {
suppressErrorToast: true,
});
},
@@ -161,7 +161,7 @@ export const userManagementService = {
*/
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
const formData = new FormData();
formData.append('enabled', enabled.toString());
formData.append("enabled", enabled.toString());
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
suppressErrorToast: true,
});
@@ -173,10 +173,10 @@ export const userManagementService = {
async deleteUser(user: User, options?: { notifyUser?: boolean }): Promise<void> {
if (isSupabaseConfigured && supabase) {
if (!user.email) {
throw new Error('Email missing for this user. Please contact support for manual removal.');
throw new Error("Email missing for this user. Please contact support for manual removal.");
}
const { error } = await supabase.functions.invoke('delete-user', {
const { error } = await supabase.functions.invoke("delete-user", {
body: {
target_email: user.email,
notify_user: options?.notifyUser ?? true,
@@ -184,7 +184,7 @@ export const userManagementService = {
});
if (error) {
throw new Error(error.message || 'Supabase deletion failed');
throw new Error(error.message || "Supabase deletion failed");
}
return;
}
@@ -197,19 +197,15 @@ export const userManagementService = {
*/
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
const formData = new FormData();
formData.append('emails', data.emails);
formData.append('role', data.role);
formData.append("emails", data.emails);
formData.append("role", data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
formData.append("teamId", data.teamId.toString());
}
const response = await apiClient.post<InviteUsersResponse>(
'/api/v1/user/admin/inviteUsers',
formData,
{
suppressErrorToast: true, // Component will handle error display
}
);
const response = await apiClient.post<InviteUsersResponse>("/api/v1/user/admin/inviteUsers", formData, {
suppressErrorToast: true, // Component will handle error display
});
return response.data;
},
@@ -221,26 +217,22 @@ export const userManagementService = {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append('email', data.email);
formData.append("email", data.email);
}
formData.append('role', data.role);
formData.append("role", data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
formData.append("teamId", data.teamId.toString());
}
if (data.expiryHours) {
formData.append('expiryHours', data.expiryHours.toString());
formData.append("expiryHours", data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
formData.append("sendEmail", data.sendEmail.toString());
}
const response = await apiClient.post<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
}
);
const response = await apiClient.post<InviteLinkResponse>("/api/v1/invite/generate", formData, {
suppressErrorToast: true,
});
return response.data;
},
@@ -249,7 +241,7 @@ export const userManagementService = {
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
const response = await apiClient.get<{ invites: InviteToken[] }>("/api/v1/invite/list");
return response.data.invites;
},
@@ -266,7 +258,7 @@ export const userManagementService = {
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
const response = await apiClient.post<{ deletedCount: number }>("/api/v1/invite/cleanup");
return response.data;
},
};
+9 -9
View File
@@ -2,7 +2,7 @@
* User service for handling user-related API calls
*/
const API_BASE = '/api/v1';
const API_BASE = "/api/v1";
/**
* Synchronizes user upgrade from anonymous to authenticated status with the backend.
@@ -14,7 +14,7 @@ const API_BASE = '/api/v1';
* @returns Promise with the synchronization result
*/
export const synchronizeUserUpgrade = async (
authMethod?: string
authMethod?: string,
): Promise<{
message: string;
userId: string;
@@ -22,22 +22,22 @@ export const synchronizeUserUpgrade = async (
}> => {
const formData = new URLSearchParams();
if (authMethod) {
formData.append('authMethod', authMethod);
formData.append("authMethod", authMethod);
}
const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
"Content-Type": "application/x-www-form-urlencoded",
},
credentials: 'include', // Include cookies for authentication
credentials: "include", // Include cookies for authentication
body: formData.toString(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Failed to synchronize user upgrade' }));
throw new Error(errorData.error || 'Failed to synchronize user upgrade');
const errorData = await response.json().catch(() => ({ error: "Failed to synchronize user upgrade" }));
throw new Error(errorData.error || "Failed to synchronize user upgrade");
}
return response.json();
};
};
+81 -38
View File
@@ -1,34 +1,42 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock localStorage for tests
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { store = {}; }),
get length() { return Object.keys(store).length; },
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] || null),
};
})();
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
Object.defineProperty(global, "localStorage", { value: localStorageMock });
// Mock Supabase for tests
vi.mock('@app/auth/supabase', () => ({
vi.mock("@app/auth/supabase", () => ({
supabase: {
auth: {
getSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
refreshSession: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
}
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } }),
},
},
debugAuthEvents: vi.fn()
}))
debugAuthEvents: vi.fn(),
}));
// Mock i18next for tests
vi.mock('react-i18next', () => ({
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
@@ -36,16 +44,16 @@ vi.mock('react-i18next', () => ({
},
}),
initReactI18next: {
type: '3rdParty',
type: "3rdParty",
init: vi.fn(),
},
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock i18next-http-backend
vi.mock('i18next-http-backend', () => ({
vi.mock("i18next-http-backend", () => ({
default: {
type: 'backend',
type: "backend",
init: vi.fn(),
read: vi.fn(),
save: vi.fn(),
@@ -53,12 +61,12 @@ vi.mock('i18next-http-backend', () => ({
}));
// Mock window.URL.createObjectURL and revokeObjectURL for tests
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
global.URL.createObjectURL = vi.fn(() => "mocked-url");
global.URL.revokeObjectURL = vi.fn();
// Mock File and Blob API methods that aren't available in jsdom
if (!globalThis.File.prototype.arrayBuffer) {
globalThis.File.prototype.arrayBuffer = function() {
globalThis.File.prototype.arrayBuffer = function () {
// Return a simple ArrayBuffer with some mock data
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
@@ -68,7 +76,7 @@ if (!globalThis.File.prototype.arrayBuffer) {
}
if (!globalThis.Blob.prototype.arrayBuffer) {
globalThis.Blob.prototype.arrayBuffer = function() {
globalThis.Blob.prototype.arrayBuffer = function () {
// Return a simple ArrayBuffer with some mock data
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
@@ -86,7 +94,7 @@ for (let i = 0; i < 32; i++) {
}
// Force override crypto.subtle to avoid Node.js native implementation
Object.defineProperty(globalThis, 'crypto', {
Object.defineProperty(globalThis, "crypto", {
value: {
subtle: {
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: BufferSource) => {
@@ -132,9 +140,9 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
}));
// Mock matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation(query => ({
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
@@ -147,30 +155,65 @@ Object.defineProperty(window, 'matchMedia', {
});
// Mock DOMMatrix for PDF.js tests
Object.defineProperty(global, 'DOMMatrix', {
Object.defineProperty(global, "DOMMatrix", {
value: class MockDOMMatrix {
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0;
m11 = 1; m12 = 0; m13 = 0; m14 = 0;
m21 = 0; m22 = 1; m23 = 0; m24 = 0;
m31 = 0; m32 = 0; m33 = 1; m34 = 0;
m41 = 0; m42 = 0; m43 = 0; m44 = 1;
a = 1;
b = 0;
c = 0;
d = 1;
e = 0;
f = 0;
m11 = 1;
m12 = 0;
m13 = 0;
m14 = 0;
m21 = 0;
m22 = 1;
m23 = 0;
m24 = 0;
m31 = 0;
m32 = 0;
m33 = 1;
m34 = 0;
m41 = 0;
m42 = 0;
m43 = 0;
m44 = 1;
is2D = true;
isIdentity = true;
toString() { return 'matrix(1, 0, 0, 1, 0, 0)'; }
scale() { return this; }
translate() { return this; }
rotate() { return this; }
inverse() { return this; }
multiply() { return this; }
toString() {
return "matrix(1, 0, 0, 1, 0, 0)";
}
scale() {
return this;
}
translate() {
return this;
}
rotate() {
return this;
}
inverse() {
return this;
}
multiply() {
return this;
}
static fromFloat32Array() { return new MockDOMMatrix(); }
static fromFloat64Array() { return new MockDOMMatrix(); }
static fromMatrix() { return new MockDOMMatrix(); }
static fromFloat32Array() {
return new MockDOMMatrix();
}
static fromFloat64Array() {
return new MockDOMMatrix();
}
static fromMatrix() {
return new MockDOMMatrix();
}
},
writable: true,
configurable: true,
})
});
// Set global test timeout to prevent hangs
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 });
+42 -42
View File
@@ -2,11 +2,11 @@
:root {
/* Orange scale (used for warning toasts) */
--color-orange-50: #FFF4ED;
--color-orange-100: #FFE1CC;
--color-orange-200: #FFB089;
--color-orange-300: #FF7A45;
--color-orange-400: #D84A1B;
--color-orange-50: #fff4ed;
--color-orange-100: #ffe1cc;
--color-orange-200: #ffb089;
--color-orange-300: #ff7a45;
--color-orange-400: #d84a1b;
/* Amber scale (trial/warning emphasis) */
--color-amber-50: #fffbeb;
@@ -21,8 +21,8 @@
--color-amber-900: #78350f;
/* Subcategory / divider vars (light) */
--tool-subcategory-text-color-light: #9CA3AF;
--tool-subcategory-rule-color-light: #E5E7EB;
--tool-subcategory-text-color-light: #9ca3af;
--tool-subcategory-rule-color-light: #e5e7eb;
--text-divider-rule-color: var(--color-gray-200);
--text-divider-label-color: var(--color-gray-400);
--text-divider-rule-color-light: var(--color-gray-200);
@@ -32,8 +32,8 @@
--auth-input-bg: #f9fafb;
--auth-input-border: #e5e7eb;
--auth-input-text: #1f2937;
--auth-label-text: #2B3230;
--auth-button-bg: #AF3434;
--auth-label-text: #2b3230;
--auth-button-bg: #af3434;
--auth-button-text: #ffffff;
--auth-magic-button-bg: #8b5cf6;
--auth-magic-button-text: #ffffff;
@@ -42,33 +42,33 @@
--auth-input-bg-light-only: #f9fafb;
--auth-input-border-light-only: #e5e7eb;
--auth-input-text-light-only: #1f2937;
--auth-label-text-light-only: #2B3230;
--auth-button-bg-light-only: #AF3434;
--auth-label-text-light-only: #2b3230;
--auth-button-bg-light-only: #af3434;
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #8b5cf6;
--auth-magic-button-text-light-only: #ffffff;
--auth-bg-color-light-only: #ffffff;
--auth-card-bg-light-only: #ffffff;
--auth-text-primary-light-only: #2B3230;
--auth-text-primary-light-only: #2b3230;
--auth-text-secondary-light-only: #1f2937;
--auth-border-color-light-only: #e5e7eb;
--auth-border-focus-light-only: #cbd5e1;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.15);
/* App Config Modal colors (light mode) */
--modal-nav-bg: #F5F6F8;
--modal-nav-section-title: #6B7280;
--modal-nav-bg: #f5f6f8;
--modal-nav-section-title: #6b7280;
--modal-nav-item: #374151;
--modal-nav-item-active: #0A8BFF;
--modal-nav-item-active: #0a8bff;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
/* API usage progress bar colors (light mode) */
--usage-weekly-active: #3B82F6;
--usage-bought-active: #14B8A6;
--usage-total-used: #3B82F6;
--usage-inactive: #E5E7EB;
--usage-weekly-active: #3b82f6;
--usage-bought-active: #14b8a6;
--usage-total-used: #3b82f6;
--usage-inactive: #e5e7eb;
/* API Keys section colors (light mode) */
--api-keys-card-bg: #ffffff;
@@ -90,15 +90,15 @@
--spdf-compare-added-badge-fg: var(--color-green-500);
--spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25);
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
--compare-page-label-bg: #2A2F36;
--compare-page-label-fg: #D0D6DC;
--compare-page-label-bg: #2a2f36;
--compare-page-label-fg: #d0d6dc;
/* Orange scale (dark mode mirrors light values to match UI) */
--color-orange-50: #FFF4ED;
--color-orange-100: #FFE1CC;
--color-orange-200: #FFB089;
--color-orange-300: #FF7A45;
--color-orange-400: #D84A1B;
--color-orange-50: #fff4ed;
--color-orange-100: #ffe1cc;
--color-orange-200: #ffb089;
--color-orange-300: #ff7a45;
--color-orange-400: #d84a1b;
/* Auth page colors (light mode only - auth pages force light mode) */
--auth-bg-color-light-only: #f3f4f6;
@@ -110,7 +110,7 @@
--auth-input-text-light-only: #111827;
--auth-border-focus-light-only: #3b82f6;
--auth-focus-ring-light-only: rgba(59, 130, 246, 0.1);
--auth-button-bg-light-only: #AF3434;
--auth-button-bg-light-only: #af3434;
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #e5e7eb;
--auth-magic-button-text-light-only: #374151;
@@ -122,29 +122,29 @@
--tool-subcategory-text-color-light: #9ca3af;
/* API usage progress bar colors (dark mode) */
--usage-weekly-active: #60A5FA;
--usage-bought-active: #34D399;
--usage-total-used: #FFFFFF;
--usage-inactive: #43464B;
--usage-weekly-active: #60a5fa;
--usage-bought-active: #34d399;
--usage-total-used: #ffffff;
--usage-inactive: #43464b;
/* API Keys section colors (dark mode) */
--api-keys-card-bg: #2A2F36;
--api-keys-card-border: #3A4047;
--api-keys-card-bg: #2a2f36;
--api-keys-card-border: #3a4047;
--api-keys-card-shadow: none;
--api-keys-input-bg: #1F2329;
--api-keys-input-border: #3A4047;
--api-keys-button-bg: #3A4047;
--api-keys-button-color: #D0D6DC;
--api-keys-input-bg: #1f2329;
--api-keys-input-border: #3a4047;
--api-keys-button-bg: #3a4047;
--api-keys-button-color: #d0d6dc;
--text-divider-rule-color: var(--tool-subcategory-rule-color);
--text-divider-label-color: var(--text-muted);
/* App Config Modal colors (dark mode) */
--modal-nav-bg: #1F2329;
--modal-nav-section-title: #9CA3AF;
--modal-nav-item: #D0D6DC;
--modal-nav-item-active: #0A8BFF;
--modal-nav-bg: #1f2329;
--modal-nav-section-title: #9ca3af;
--modal-nav-item: #d0d6dc;
--modal-nav-item-active: #0a8bff;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
--modal-content-bg: #2A2F36;
--modal-content-bg: #2a2f36;
--modal-header-border: rgba(255, 255, 255, 0.08);
}
+1 -4
View File
@@ -2,11 +2,8 @@
// Keep values identical to their original inline usages.
// Re-export all core z-index constants
export * from '@core/styles/zIndex';
export * from "@core/styles/zIndex";
// SaaS-specific z-index constants
export const Z_ANALYTICS_MODAL = 1301;
export const Z_INDEX_OVER_SETTINGS_MODAL = 1400;
+4 -18
View File
@@ -3,24 +3,10 @@
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": [
"src/saas/*",
"src/proprietary/*",
"src/core/*"
],
"@proprietary/*": [
"src/proprietary/*"
],
"@core/*": [
"src/core/*"
]
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"."
]
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
}
+1 -3
View File
@@ -7,7 +7,7 @@ export interface FractionData {
color: string;
}
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export type TooltipPosition = "top" | "bottom" | "left" | "right";
export interface StackedBarChartProps {
fractions: FractionData[];
@@ -26,5 +26,3 @@ export interface TooltipData {
fractions: FractionData[];
isDark: boolean;
}
+2 -4
View File
@@ -19,8 +19,8 @@ export interface CreditSummary {
export interface SubscriptionInfo {
id?: string;
status: 'active' | 'inactive' | 'cancelled' | 'expired';
tier: 'free' | 'basic' | 'premium' | 'enterprise';
status: "active" | "inactive" | "cancelled" | "expired";
tier: "free" | "basic" | "premium" | "enterprise";
startDate?: string; // ISO date string
endDate?: string; // ISO date string
creditsPerWeek?: number;
@@ -33,5 +33,3 @@ export interface CreditCheckResult {
requiredCredits: number;
shortfall?: number;
}
+8 -8
View File
@@ -20,7 +20,7 @@ export interface StripeWebhookEvent {
data: {
object: {
id: string;
payment_status: 'paid' | 'unpaid';
payment_status: "paid" | "unpaid";
customer_details?: {
email: string;
name?: string;
@@ -58,10 +58,10 @@ export interface StripeApiError {
// Webhook event types that the backend should handle
export type StripeWebhookEventType =
| 'checkout.session.completed'
| 'checkout.session.expired'
| 'payment_intent.succeeded'
| 'payment_intent.payment_failed'
| 'customer.subscription.created'
| 'customer.subscription.updated'
| 'customer.subscription.deleted';
| "checkout.session.completed"
| "checkout.session.expired"
| "payment_intent.succeeded"
| "payment_intent.payment_failed"
| "customer.subscription.created"
| "customer.subscription.updated"
| "customer.subscription.deleted";
+4 -6
View File
@@ -1,7 +1,7 @@
// Utility helpers to open the settings/config modal programmatically
// and optionally navigate to a specific section (e.g., 'plan').
import type { NavKey } from '@app/components/shared/config/types';
import type { NavKey } from "@app/components/shared/config/types";
export function openAppSettings(targetKey?: NavKey, notice?: string) {
try {
@@ -9,10 +9,10 @@ export function openAppSettings(targetKey?: NavKey, notice?: string) {
if (targetKey) detail.key = targetKey;
if (notice) detail.notice = notice;
// Ask the UI to open the App Config modal
window.dispatchEvent(new CustomEvent('appConfig:open', { detail }));
window.dispatchEvent(new CustomEvent("appConfig:open", { detail }));
// If a specific section is requested, navigate there once modal mounts
if (targetKey) {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: targetKey } }));
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: targetKey } }));
}
} catch (_e) {
// no-op on SSR or test environments
@@ -20,7 +20,5 @@ export function openAppSettings(targetKey?: NavKey, notice?: string) {
}
export function openPlanSettings(notice?: string) {
openAppSettings('plan', notice);
openAppSettings("plan", notice);
}
+9 -12
View File
@@ -17,21 +17,18 @@ export interface Area {
* @param pixelCrop - Pixel coordinates and dimensions of the crop area
* @returns Promise that resolves to a PNG Blob of the cropped image
*/
export async function getCroppedImage(
imageSrc: string,
pixelCrop: Area
): Promise<Blob> {
export async function getCroppedImage(imageSrc: string, pixelCrop: Area): Promise<Blob> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
try {
// Create canvas with crop dimensions
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error('Failed to get canvas context'));
reject(new Error("Failed to get canvas context"));
return;
}
@@ -54,20 +51,20 @@ export async function getCroppedImage(
0,
0,
pixelCrop.width,
pixelCrop.height
pixelCrop.height,
);
// Convert canvas to PNG blob
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to create blob from canvas'));
reject(new Error("Failed to create blob from canvas"));
return;
}
resolve(blob);
},
'image/png',
1.0 // Maximum quality
"image/png",
1.0, // Maximum quality
);
} catch (error) {
reject(error);
@@ -75,7 +72,7 @@ export async function getCroppedImage(
};
image.onerror = () => {
reject(new Error('Failed to load image'));
reject(new Error("Failed to load image"));
};
// Start loading the image
+16 -16
View File
@@ -1,47 +1,47 @@
import { URL_TO_TOOL_MAP } from '@app/utils/urlMapping'
import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping";
const SUBPATH = import.meta.env.VITE_RUN_SUBPATH.replace(/^\/|\/$/g, '') // "app" or ""
const SUBPATH = import.meta.env.VITE_RUN_SUBPATH.replace(/^\/|\/$/g, ""); // "app" or ""
/**
* Normalize pathname by stripping subpath prefix and trailing slashes
*/
export function normalizePath(pathname: string): string {
// Ensure leading slash, strip subpath prefix if configured
let p = pathname.startsWith('/') ? pathname : `/${pathname}`
let p = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (SUBPATH && p.startsWith(`/${SUBPATH}/`)) {
p = p.slice(SUBPATH.length + 1) // remove "/app"
p = p.slice(SUBPATH.length + 1); // remove "/app"
} else if (SUBPATH && p === `/${SUBPATH}`) {
p = '/'
p = "/";
}
// Strip trailing slash except root
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
return p
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
return p;
}
/**
* Check if pathname is an auth route
*/
export function isAuthRoute(pathname: string): boolean {
const p = normalizePath(pathname)
return p === '/login' || p === '/signup' || p === '/auth/callback'
const p = normalizePath(pathname);
return p === "/login" || p === "/signup" || p === "/auth/callback";
}
/**
* Check if pathname is home route
*/
export function isHomeRoute(pathname: string): boolean {
return normalizePath(pathname) === '/'
return normalizePath(pathname) === "/";
}
/**
* Check if pathname is a tool route
*/
export function isToolRoute(pathname: string): boolean {
const p = normalizePath(pathname)
const p = normalizePath(pathname);
// direct match or try without trailing slash variants if your map uses them
if (URL_TO_TOOL_MAP[p] !== undefined) return true
if (URL_TO_TOOL_MAP[p] !== undefined) return true;
// Fallback: try adding/removing trailing slash
if (URL_TO_TOOL_MAP[`${p}/`] !== undefined) return true
if (p.endsWith('/') && URL_TO_TOOL_MAP[p.slice(0, -1)] !== undefined) return true
return false
}
if (URL_TO_TOOL_MAP[`${p}/`] !== undefined) return true;
if (p.endsWith("/") && URL_TO_TOOL_MAP[p.slice(0, -1)] !== undefined) return true;
return false;
}