mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Prettier 2: Electric Boogaloo (#6113)
# Description of Changes When I added Prettier formatting in #6052, my aim was to use just the default settings in Prettier. Turns out, Prettier looks _really hard_ for any config files if it's not explicitly given one, which means that if a developer has some sort of Prettier config file lying around on their system, Prettier might find it and use it. Also, Prettier changes its defaults based on stuff in `.editorconfig` without any good way of disabling that behaviour explicitly in its config file. To solve both of these issues, I've introduced a `.prettierrc` file which sets Prettier's defaults explicitly, and then reformatted all our code _again_ in Prettier's actual default settings. This should achieve the aim of #6052 and remove the possibility for it breaking on different dev computers.
This commit is contained in:
@@ -30,7 +30,9 @@ function handleConfigLoaded(config: AppConfig) {
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}>
|
||||
<AppProviders
|
||||
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
||||
>
|
||||
<AppLayout>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
|
||||
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 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 {
|
||||
syncOAuthAvatar,
|
||||
getProfilePictureMetadata,
|
||||
type ProfilePictureMetadata,
|
||||
} from "@app/services/avatarSyncService";
|
||||
|
||||
// Extend Supabase User to include optional username for compatibility
|
||||
export type User = SupabaseUser & { username?: string };
|
||||
@@ -55,7 +77,11 @@ const AuthContext = createContext<AuthContextType>({
|
||||
profilePictureMetadata: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }),
|
||||
hasSufficientCredits: () => ({
|
||||
hasSufficientCredits: false,
|
||||
currentBalance: 0,
|
||||
requiredCredits: 0,
|
||||
}),
|
||||
updateCredits: () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshProStatus: async () => {},
|
||||
@@ -69,12 +95,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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 [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 [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||
useState<ProfilePictureMetadata | null>(null);
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
@@ -89,14 +122,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug("[Auth Debug] Fetching credits for user:", currentSession.user.id);
|
||||
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,
|
||||
maxCredits:
|
||||
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
|
||||
creditsUsed:
|
||||
apiCredits.weeklyCreditsAllocated -
|
||||
apiCredits.weeklyCreditsRemaining +
|
||||
@@ -139,13 +176,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping pro status fetch");
|
||||
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);
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching pro status for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
const { data: proStatus, error } = await supabase.rpc("is_pro");
|
||||
|
||||
if (error) {
|
||||
@@ -173,16 +215,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping trial status fetch");
|
||||
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);
|
||||
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")
|
||||
.select(
|
||||
"status, trial_end, has_payment_method, scheduled_subscription_id",
|
||||
)
|
||||
.in("status", ["trialing", "incomplete_expired", "canceled"])
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
@@ -197,7 +246,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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));
|
||||
const daysRemaining = Math.ceil(
|
||||
(trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
setTrialStatus({
|
||||
isTrialing: data.status === "trialing" && daysRemaining > 0,
|
||||
@@ -233,7 +284,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping profile picture fetch");
|
||||
console.debug(
|
||||
"[Auth Debug] No user session, skipping profile picture fetch",
|
||||
);
|
||||
setProfilePictureUrl(null);
|
||||
return;
|
||||
}
|
||||
@@ -242,16 +295,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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);
|
||||
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);
|
||||
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");
|
||||
console.debug(
|
||||
"[Auth Debug] Profile picture URL fetched successfully",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch profile picture:", error);
|
||||
@@ -270,18 +333,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping profile picture metadata fetch");
|
||||
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);
|
||||
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);
|
||||
console.debug(
|
||||
"[Auth Debug] Profile picture metadata fetched:",
|
||||
metadata,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch profile picture metadata:", error);
|
||||
console.debug(
|
||||
"[Auth Debug] Failed to fetch profile picture metadata:",
|
||||
error,
|
||||
);
|
||||
setProfilePictureMetadata(null);
|
||||
}
|
||||
},
|
||||
@@ -294,7 +370,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const updateCredits = useCallback(
|
||||
(newBalance: number) => {
|
||||
console.debug("[Auth Debug] Updating credit balance:", { from: creditBalance, to: newBalance });
|
||||
console.debug("[Auth Debug] Updating credit balance:", {
|
||||
from: creditBalance,
|
||||
to: newBalance,
|
||||
});
|
||||
setCreditBalance(newBalance);
|
||||
// Also update the creditSummary if it exists
|
||||
if (creditSummary) {
|
||||
@@ -313,7 +392,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
(requiredCredits: number): CreditCheckResult => {
|
||||
const currentBalance = creditBalance ?? 0;
|
||||
const hasSufficient = currentBalance >= requiredCredits;
|
||||
console.debug("[Auth Debug] Credit check:", { requiredCredits, currentBalance, hasSufficient });
|
||||
console.debug("[Auth Debug] Credit check:", {
|
||||
requiredCredits,
|
||||
currentBalance,
|
||||
hasSufficient,
|
||||
});
|
||||
|
||||
return {
|
||||
hasSufficientCredits: hasSufficient,
|
||||
@@ -340,7 +423,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth Debug] Unexpected error during session refresh:", err);
|
||||
console.error(
|
||||
"[Auth Debug] Unexpected error during session refresh:",
|
||||
err,
|
||||
);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -396,7 +482,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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);
|
||||
@@ -411,7 +500,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
@@ -477,7 +569,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setTimeout(() => {
|
||||
fetchProfilePicture(newSession).finally(() => {
|
||||
setLoading(false);
|
||||
console.debug("[Auth Debug] User data fully loaded after sign in");
|
||||
console.debug(
|
||||
"[Auth Debug] User data fully loaded after sign in",
|
||||
);
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
@@ -493,7 +587,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
]).then(() => {
|
||||
console.debug("[Auth Debug] User data refreshed after token refresh");
|
||||
console.debug(
|
||||
"[Auth Debug] User data refreshed after token refresh",
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (event === "USER_UPDATED") {
|
||||
@@ -503,8 +599,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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);
|
||||
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");
|
||||
@@ -513,7 +616,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Synchronize with backend
|
||||
synchronizeUserUpgrade(upgradeProvider || undefined)
|
||||
.then(() => {
|
||||
console.debug("[Auth Debug] User upgrade synchronized successfully");
|
||||
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) {
|
||||
@@ -527,10 +632,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
console.debug("[Auth Debug] User data refreshed after upgrade");
|
||||
console.debug(
|
||||
"[Auth Debug] User data refreshed after upgrade",
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Auth Debug] Failed to synchronize user upgrade:", err);
|
||||
console.error(
|
||||
"[Auth Debug] Failed to synchronize user upgrade:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ if (!config.url) {
|
||||
}
|
||||
|
||||
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, {
|
||||
@@ -95,7 +97,10 @@ export const linkEmailIdentity = async (email: string, password?: string) => {
|
||||
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");
|
||||
@@ -113,7 +118,10 @@ export const linkEmailIdentity = async (email: string, password?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -13,7 +13,8 @@ const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active";
|
||||
*/
|
||||
export default function OnboardingBootstrap() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
|
||||
const { clearPendingTourRequest, setStartAfterToolModeSelection } =
|
||||
useOnboarding();
|
||||
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
@@ -38,7 +39,10 @@ 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);
|
||||
@@ -78,7 +82,10 @@ export default function OnboardingBootstrap() {
|
||||
// Keep existing logic to disable core onboarding flags
|
||||
useEffect(() => {
|
||||
// Ensure tool panel preference is set so tours are never deferred.
|
||||
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
|
||||
if (
|
||||
!preferences.toolPanelModePromptSeen ||
|
||||
!preferences.hasSelectedToolPanelMode
|
||||
) {
|
||||
updatePreference("toolPanelModePromptSeen", true);
|
||||
updatePreference("hasSelectedToolPanelMode", true);
|
||||
}
|
||||
@@ -111,5 +118,7 @@ export default function OnboardingBootstrap() {
|
||||
]);
|
||||
|
||||
// Only render modal when it should be shown to avoid running hooks unnecessarily
|
||||
return showModal ? <SaasOnboardingModal opened={showModal} onClose={handleClose} /> : null;
|
||||
return showModal ? (
|
||||
<SaasOnboardingModal opened={showModal} onClose={handleClose} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -45,12 +45,20 @@ export default function TrialExpiredBootstrap() {
|
||||
}
|
||||
|
||||
// Check if all conditions are met to show the modal
|
||||
const isExpired = trialStatus.status === "incomplete_expired" || trialStatus.status === "canceled";
|
||||
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
|
||||
const 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", {
|
||||
@@ -92,7 +100,11 @@ export default function TrialExpiredBootstrap() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrialExpiredModal opened={showModal && !checkoutOpened} onClose={handleClose} onSubscribe={handleSubscribe} />
|
||||
<TrialExpiredModal
|
||||
opened={showModal && !checkoutOpened}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
@@ -103,7 +115,9 @@ 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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,10 +50,16 @@ export function GuestUserBanner({ className = "" }: GuestUserBannerProps) {
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
@@ -12,11 +12,19 @@ export function RequireAuth({ fallbackPath = "/login" }: RequireAuthProps) {
|
||||
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");
|
||||
console.warn(
|
||||
"[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost",
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
|
||||
try {
|
||||
// Prepare user data options
|
||||
const userInfo: { name?: string; email?: string } = {};
|
||||
if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
|
||||
if (user.user_metadata?.full_name)
|
||||
userInfo.name = user.user_metadata.full_name;
|
||||
if (user.email) userInfo.email = user.email;
|
||||
|
||||
const options = {
|
||||
@@ -45,7 +46,10 @@ 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;
|
||||
|
||||
@@ -24,14 +24,25 @@ 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") {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling icon"
|
||||
className={styles.downloadIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -40,9 +51,16 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === "rocket" && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -72,7 +90,12 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
|
||||
<AnimatedSlideBackground
|
||||
@@ -96,18 +119,27 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
<div
|
||||
key={`body-${currentSlide.key}`}
|
||||
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||
>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
|
||||
<OnboardingStepper
|
||||
totalSteps={totalSteps}
|
||||
activeStep={currentStep}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
@@ -14,9 +18,18 @@ interface RenderButtonsProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||
export function renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction,
|
||||
t,
|
||||
}: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
@@ -63,7 +76,9 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +87,12 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
import { FLOW_SEQUENCES, SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import {
|
||||
FLOW_SEQUENCES,
|
||||
SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface FlowConfig {
|
||||
type: "saas-trial" | "saas-paid";
|
||||
@@ -13,12 +16,16 @@ 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 {
|
||||
|
||||
@@ -11,24 +11,35 @@ 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 }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
// Determine which message to show based on payment method
|
||||
const afterTrialMessage = trialStatus.hasScheduledSub
|
||||
? t("onboarding.freeTrial.afterTrialWithPayment", "Your Pro subscription will start automatically when the trial ends.")
|
||||
? 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.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.",
|
||||
@@ -37,8 +48,14 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
// 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 });
|
||||
? 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" }}>
|
||||
@@ -56,9 +73,19 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.25rem", fontWeight: "bold", marginBottom: "0.5rem" }}>{daysText}</div>
|
||||
<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 })}
|
||||
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", {
|
||||
date: trialEndDate,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
|
||||
@@ -66,7 +93,9 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
|
||||
export default function FreeTrialSlide({
|
||||
trialStatus,
|
||||
}: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "free-trial",
|
||||
title: <FreeTrialSlideTitle />,
|
||||
|
||||
@@ -24,7 +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>("");
|
||||
@@ -44,7 +47,10 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac-apple":
|
||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
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":
|
||||
@@ -58,8 +64,16 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
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: "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);
|
||||
@@ -71,7 +85,10 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
@@ -84,7 +101,8 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
}
|
||||
}, [flowSlideIds.length, currentStep]);
|
||||
|
||||
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const currentSlideId =
|
||||
flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
// Create slide with appropriate params - must be called before any early returns
|
||||
@@ -97,7 +115,14 @@ export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingSta
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
trialStatus: trialStatus ?? undefined,
|
||||
});
|
||||
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
|
||||
}, [
|
||||
slideDefinition,
|
||||
os.label,
|
||||
os.url,
|
||||
osOptions,
|
||||
handleDownloadUrlChange,
|
||||
trialStatus,
|
||||
]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
|
||||
@@ -10,7 +10,10 @@ import { createSaasConfigNavSections } from "@app/components/shared/config/saasC
|
||||
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 {
|
||||
Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
Z_INDEX_OVER_SETTINGS_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -36,31 +39,44 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
return () => window.removeEventListener("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;
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { key?: NavKey; notice?: string }
|
||||
| undefined;
|
||||
if (detail?.notice && (detail?.key ? detail.key === "plan" : true)) {
|
||||
setNotice(detail.notice);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:notice", handler as EventListener);
|
||||
return () => window.removeEventListener("appConfig:notice", handler as EventListener);
|
||||
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);
|
||||
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]);
|
||||
@@ -68,7 +84,10 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log("[AppConfigModal] Credit balance updated while viewing Plan:", creditBalance);
|
||||
console.log(
|
||||
"[AppConfigModal] Credit balance updated while viewing Plan:",
|
||||
creditBalance,
|
||||
);
|
||||
}, [opened, active, creditBalance]);
|
||||
|
||||
const colors = useMemo(
|
||||
@@ -154,7 +173,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
@@ -162,10 +183,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : "transparent",
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
<LocalIcon
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
@@ -194,10 +222,22 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
{active === "plan" && notice ? (
|
||||
<span style={{ marginLeft: 8, fontWeight: 600, color: "var(--mantine-color-yellow-7)" }}>– {notice}</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-yellow-7)",
|
||||
}}
|
||||
>
|
||||
– {notice}
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
@@ -100,8 +100,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
<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" ? (
|
||||
<LocalIcon
|
||||
@@ -111,15 +122,28 @@ 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 }}>
|
||||
<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>
|
||||
@@ -132,7 +156,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
styles={
|
||||
buttonTextColor
|
||||
? {
|
||||
|
||||
@@ -38,14 +38,18 @@ 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,
|
||||
},
|
||||
});
|
||||
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");
|
||||
@@ -56,7 +60,11 @@ export function ManageBillingButton({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-black text-white"
|
||||
>
|
||||
{loading ? "Opening…" : children}
|
||||
</Button>
|
||||
{err && <div className="mt-2 text-red-600">{err}</div>}
|
||||
|
||||
@@ -9,7 +9,12 @@ 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 }) => {
|
||||
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 = {
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 {
|
||||
EmbeddedCheckoutProvider,
|
||||
EmbeddedCheckout,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
@@ -26,7 +29,12 @@ 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;
|
||||
@@ -65,16 +73,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
try {
|
||||
setState({ status: "loading" });
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("create-checkout", {
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false,
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"create-checkout",
|
||||
{
|
||||
body: {
|
||||
purchase_type: purchaseType,
|
||||
ui_mode: "embedded",
|
||||
plan: planId,
|
||||
credits_pack: creditsPack,
|
||||
callback_base_url: window.location.origin,
|
||||
trial_conversion: isTrialConversion || false,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to create checkout session");
|
||||
@@ -100,7 +111,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
},
|
||||
});
|
||||
} 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,
|
||||
@@ -120,7 +134,12 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -136,12 +155,21 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
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]);
|
||||
|
||||
@@ -175,7 +203,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
|
||||
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(
|
||||
@@ -184,7 +215,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -11,12 +11,22 @@ interface TrialExpiredModalProps {
|
||||
onSubscribe: () => void;
|
||||
}
|
||||
|
||||
export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
|
||||
export function TrialExpiredModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: TrialExpiredModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use CSS variables for theme colors
|
||||
const amberColor = getComputedStyle(document.documentElement).getPropertyValue("--color-amber-500").trim() || "#f59e0b";
|
||||
const redColor = getComputedStyle(document.documentElement).getPropertyValue("--color-red-500").trim() || "#ef4444";
|
||||
const 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 = [
|
||||
@@ -69,10 +79,20 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
<Stack
|
||||
gap={0}
|
||||
className={styles.modalContent}
|
||||
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
|
||||
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" }} />
|
||||
@@ -90,7 +110,9 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
}}
|
||||
>
|
||||
<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}`}>
|
||||
@@ -103,7 +125,10 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
|
||||
|
||||
<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>
|
||||
|
||||
@@ -152,7 +177,8 @@ 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))",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))",
|
||||
color: "#FFFFFF",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -46,10 +46,13 @@ 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",
|
||||
@@ -94,7 +97,14 @@ export function TrialStatusBanner() {
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
|
||||
}, [
|
||||
shouldShowBanner,
|
||||
trialStatus,
|
||||
setBanner,
|
||||
t,
|
||||
handleOpenCheckout,
|
||||
handleDismiss,
|
||||
]);
|
||||
|
||||
const handleCheckoutSuccess = () => {
|
||||
// Refresh to hide banner and show updated plan
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
createRoundedRectPath,
|
||||
createScale,
|
||||
} from "@app/components/shared/charts/utils/d3Utils";
|
||||
|
||||
export default function StackedBarChart({
|
||||
fractions,
|
||||
@@ -27,10 +37,16 @@ export default function StackedBarChart({
|
||||
|
||||
// Memoize theme detection to avoid recalculation
|
||||
const theme = useMemo(() => detectTheme(), []);
|
||||
const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]);
|
||||
const themeVars = useMemo(
|
||||
() => getChartThemeVars(theme.isDark),
|
||||
[theme.isDark],
|
||||
);
|
||||
|
||||
// Memoize tooltip positioner
|
||||
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
|
||||
const tooltipPositioner = useMemo(
|
||||
() => createTooltipPositioner(tooltipPosition),
|
||||
[tooltipPosition],
|
||||
);
|
||||
|
||||
const positionTooltip = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -70,7 +86,10 @@ export default function StackedBarChart({
|
||||
container.innerHTML = "";
|
||||
|
||||
// Calculate total capacity (sum of all denominators)
|
||||
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
|
||||
const totalCapacity = fractions.reduce(
|
||||
(sum: number, fraction: FractionData) => sum + fraction.denominator,
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalCapacity === 0 && !loading) return;
|
||||
|
||||
@@ -150,21 +169,35 @@ export default function StackedBarChart({
|
||||
.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,
|
||||
});
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: true,
|
||||
topRight: false,
|
||||
bottomLeft: true,
|
||||
bottomRight: false,
|
||||
},
|
||||
);
|
||||
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,
|
||||
});
|
||||
const path = createRoundedRectPath(
|
||||
xPos,
|
||||
0,
|
||||
segWidth,
|
||||
height,
|
||||
radius,
|
||||
{
|
||||
topLeft: false,
|
||||
topRight: true,
|
||||
bottomLeft: false,
|
||||
bottomRight: true,
|
||||
},
|
||||
);
|
||||
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
|
||||
} else {
|
||||
// Middle segments: no rounded edges
|
||||
@@ -190,21 +223,32 @@ export default function StackedBarChart({
|
||||
.attr("fill", "transparent")
|
||||
.style("pointer-events", "all")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
|
||||
const tooltipData: TooltipData = {
|
||||
fractions: data,
|
||||
isDark: theme.isDark,
|
||||
};
|
||||
const html = generateTooltipHTML(tooltipData);
|
||||
setTooltipContent(html);
|
||||
const tooltip = tooltipRef.current;
|
||||
if (tooltip) tooltip.style.opacity = "1";
|
||||
positionTooltip(event as unknown as MouseEvent);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
|
||||
.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);
|
||||
@@ -246,7 +290,15 @@ export default function StackedBarChart({
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -9,9 +9,19 @@ 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" }}>
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
@@ -22,7 +32,8 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
|
||||
}}
|
||||
></span>
|
||||
<span>
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
<strong>{f.name}</strong> — {f.numeratorLabel}: {f.numerator} ·{" "}
|
||||
{f.denominatorLabel}: {f.denominator - f.numerator}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -102,9 +102,19 @@ 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;
|
||||
const {
|
||||
topLeft = true,
|
||||
topRight = true,
|
||||
bottomLeft = true,
|
||||
bottomRight = true,
|
||||
} = corners;
|
||||
|
||||
if (width <= 0 || height <= 0) return "";
|
||||
|
||||
@@ -162,7 +172,10 @@ 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,10 +13,13 @@ export interface ThemeInfo {
|
||||
* @returns ThemeInfo object with theme detection results
|
||||
*/
|
||||
export function detectTheme(): ThemeInfo {
|
||||
const rootEl = typeof document !== "undefined" ? document.documentElement : null;
|
||||
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;
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const isDark = schemeAttr ? schemeAttr === "dark" : prefersDark;
|
||||
|
||||
@@ -48,7 +51,10 @@ export function getChartThemeVars(isDark: boolean) {
|
||||
* @param tooltipElement The tooltip DOM element
|
||||
* @param isDark Whether the theme is dark
|
||||
*/
|
||||
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
|
||||
export function applyTooltipStyles(
|
||||
tooltipElement: HTMLElement,
|
||||
isDark: boolean,
|
||||
) {
|
||||
const themeVars = getChartThemeVars(isDark);
|
||||
|
||||
tooltipElement.style.background = themeVars.background;
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
export type TooltipPosition = "top" | "bottom" | "left" | "right";
|
||||
|
||||
export interface TooltipPositioner {
|
||||
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
|
||||
positionTooltip: (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => void;
|
||||
hideTooltip: (tooltip: HTMLElement) => void;
|
||||
}
|
||||
|
||||
@@ -14,8 +18,14 @@ export interface TooltipPositioner {
|
||||
* @param position The tooltip position preference
|
||||
* @returns TooltipPositioner object with positioning functions
|
||||
*/
|
||||
export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner {
|
||||
const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => {
|
||||
export function createTooltipPositioner(
|
||||
position: TooltipPosition,
|
||||
): TooltipPositioner {
|
||||
const positionTooltip = (
|
||||
event: MouseEvent,
|
||||
tooltip: HTMLElement,
|
||||
container: HTMLElement,
|
||||
) => {
|
||||
const bounds = container.getBoundingClientRect();
|
||||
const offsetX = event.clientX - bounds.left;
|
||||
const offsetY = event.clientY - bounds.top;
|
||||
|
||||
@@ -12,7 +12,12 @@ 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
|
||||
@@ -75,9 +80,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
}, []);
|
||||
|
||||
// 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 () => {
|
||||
@@ -110,7 +118,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Error cropping image:", err);
|
||||
setError(t("config.account.profilePicture.cropper.cropError", "Failed to crop image. Please try again."));
|
||||
setError(
|
||||
t(
|
||||
"config.account.profilePicture.cropper.cropError",
|
||||
"Failed to crop image. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -120,7 +133,10 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
<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}
|
||||
@@ -152,18 +168,33 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ fi
|
||||
<Text size="sm" fw={500}>
|
||||
{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")}
|
||||
</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>
|
||||
|
||||
@@ -18,7 +18,15 @@ export default function ApiKeys() {
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
||||
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey();
|
||||
const {
|
||||
apiKey,
|
||||
isLoading: apiKeyLoading,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
error: apiKeyError,
|
||||
refetch,
|
||||
hasAttempted,
|
||||
} = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
try {
|
||||
@@ -40,7 +48,9 @@ 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);
|
||||
@@ -51,15 +61,26 @@ export default function ApiKeys() {
|
||||
<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.")}{" "}
|
||||
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
|
||||
{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")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
@@ -77,7 +98,12 @@ export default function ApiKeys() {
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Text fw={500}>{t("config.apiKeys.label", "API Key")}</Text>
|
||||
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: "1rem" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{ gap: "1rem" }}
|
||||
>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
|
||||
{t(
|
||||
"config.apiKeys.guestInfo",
|
||||
@@ -124,7 +150,11 @@ export default function ApiKeys() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from "@app/auth/supabase";
|
||||
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";
|
||||
@@ -70,7 +75,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -87,7 +97,12 @@ 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;
|
||||
@@ -96,11 +111,13 @@ 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");
|
||||
@@ -127,7 +144,9 @@ 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");
|
||||
@@ -161,13 +180,20 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
await refreshProfilePictureMetadata();
|
||||
setSuccess(
|
||||
t("config.account.profilePicture.switchedToCustom", "Switched to custom picture. You can now upload your own."),
|
||||
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);
|
||||
}
|
||||
@@ -195,17 +221,23 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
// Refresh the session to reflect changes
|
||||
await refreshSession();
|
||||
|
||||
setSuccess("Account upgraded successfully! You can now sign in with your email.");
|
||||
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);
|
||||
@@ -224,7 +256,10 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
window.location.href = result.data.url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Failed to upgrade account with ${provider}`;
|
||||
setUpgradeError(errorMessage);
|
||||
setIsLoading(false);
|
||||
sessionStorage.removeItem("pendingUpgrade");
|
||||
@@ -242,12 +277,18 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
await signOut();
|
||||
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);
|
||||
showToast({
|
||||
alertType: "error",
|
||||
title: t("config.account.overview.deleteFailedTitle", "Unable to delete account"),
|
||||
title: t(
|
||||
"config.account.overview.deleteFailedTitle",
|
||||
"Unable to delete account",
|
||||
),
|
||||
body: message,
|
||||
expandable: true,
|
||||
location: "top-right",
|
||||
@@ -264,26 +305,61 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
};
|
||||
|
||||
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" }}>
|
||||
<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.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>
|
||||
@@ -296,11 +372,26 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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 && (
|
||||
@@ -317,26 +408,61 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
{isOAuthPicture ? (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
<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")}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUseCustomPicture}
|
||||
disabled={profileUploading}
|
||||
>
|
||||
{t(
|
||||
"config.account.profilePicture.useCustom",
|
||||
"Use custom picture",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="center" gap="md">
|
||||
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
|
||||
<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}
|
||||
@@ -349,12 +475,19 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Button variant="outline" onClick={handleProfileRemove} disabled={!profilePictureUrl || profileUploading}>
|
||||
<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>
|
||||
@@ -376,11 +509,26 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
{isAnonymous && (
|
||||
<div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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>
|
||||
|
||||
@@ -398,7 +546,10 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
|
||||
<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" }}>
|
||||
{oauthProviders
|
||||
@@ -418,7 +569,15 @@ 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}
|
||||
@@ -430,24 +589,39 @@ 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")}
|
||||
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}>
|
||||
@@ -470,7 +644,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Button color="red" variant="outline" onClick={() => setDeleteModalOpen(true)}>
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
>
|
||||
{t("config.account.overview.deleteAccount", "Delete Account")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -480,7 +658,10 @@ 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}
|
||||
>
|
||||
@@ -517,7 +698,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={confirmEmail.toLowerCase() !== user?.email?.toLowerCase()}
|
||||
disabled={
|
||||
confirmEmail.toLowerCase() !== user?.email?.toLowerCase()
|
||||
}
|
||||
type="submit"
|
||||
loading={isDeletingAccount}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from "@mantine/core";
|
||||
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";
|
||||
@@ -23,7 +31,12 @@ const PasswordSecurity: React.FC = () => {
|
||||
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) {
|
||||
@@ -37,13 +50,20 @@ const PasswordSecurity: React.FC = () => {
|
||||
setSuccess(null);
|
||||
|
||||
// Update to the new password directly
|
||||
const { error: updateError } = await supabase.auth.updateUser({ password: newPassword });
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
});
|
||||
if (updateError) {
|
||||
setError(updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
|
||||
setSuccess(
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
),
|
||||
);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setDidUpdate(true);
|
||||
@@ -66,13 +86,34 @@ const PasswordSecurity: React.FC = () => {
|
||||
<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" }}>
|
||||
<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
|
||||
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">
|
||||
@@ -95,10 +136,16 @@ const PasswordSecurity: React.FC = () => {
|
||||
|
||||
{didUpdate ? (
|
||||
<Alert color="green" mb="md">
|
||||
{success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
|
||||
{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")}
|
||||
@@ -107,17 +154,28 @@ const PasswordSecurity: React.FC = () => {
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t("account.confirmNewPassword", "Confirm New Password")}
|
||||
placeholder={t(
|
||||
"account.confirmNewPassword",
|
||||
"Confirm New Password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={() => setOpened(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleChangePassword}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("config.account.security.update", "Update password")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 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";
|
||||
@@ -11,8 +15,10 @@ 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 [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
|
||||
const [purchaseType, setPurchaseType] =
|
||||
useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] =
|
||||
useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>("gbp");
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
@@ -33,7 +39,10 @@ const Plan: React.FC = () => {
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open("mailto:[email protected]?subject=Enterprise Plan Inquiry", "_blank");
|
||||
window.open(
|
||||
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +102,9 @@ 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);
|
||||
@@ -112,7 +123,9 @@ const Plan: React.FC = () => {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete("action");
|
||||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
@@ -142,7 +155,8 @@ const Plan: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data;
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
|
||||
data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
@@ -174,7 +188,11 @@ const Plan: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection plans={plansArray} currentPlan={currentPlan} onUpgradeClick={handleUpgradeClick} />
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -187,19 +205,27 @@ const Plan: React.FC = () => {
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
|
||||
opened={
|
||||
checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null)
|
||||
}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : 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 || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)
|
||||
?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={trialStatus?.isTrialing && purchaseType === "subscription"}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,15 +16,23 @@ interface UsageSectionProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function UsageSection({ apiUsage, obscured, overlayMessage, loading }: UsageSectionProps) {
|
||||
export default function UsageSection({
|
||||
apiUsage,
|
||||
obscured,
|
||||
overlayMessage,
|
||||
loading,
|
||||
}: UsageSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
const weeklyUsed =
|
||||
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed =
|
||||
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
|
||||
// Totals for overall usage visualization
|
||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
||||
|
||||
const formatDate = (iso: string, withTime: boolean) => formatUTC(iso, withTime);
|
||||
const formatDate = (iso: string, withTime: boolean) =>
|
||||
formatUTC(iso, withTime);
|
||||
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
@@ -67,7 +75,11 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
||||
{loading ? <SkeletonLoader type="block" width={40} height={14} /> : totalRemaining}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={40} height={14} />
|
||||
) : (
|
||||
totalRemaining
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -104,7 +116,11 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
|
||||
</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)}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={160} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.lastApiUsage, true)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -130,7 +146,11 @@ 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>
|
||||
)}
|
||||
|
||||
+27
-6
@@ -20,18 +20,29 @@ export function useApiKey() {
|
||||
setError(null);
|
||||
try {
|
||||
// Backend is POST for get and update
|
||||
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/get-api-key");
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/get-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
// If not found, try to create one by calling update endpoint
|
||||
if (isAxiosError(e) && e.response?.status === 404) {
|
||||
try {
|
||||
const createRes = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
|
||||
const created = typeof createRes.data === "string" ? createRes.data : createRes.data.apiKey;
|
||||
const createRes = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const created =
|
||||
typeof createRes.data === "string"
|
||||
? createRes.data
|
||||
: createRes.data.apiKey;
|
||||
if (typeof created === "string") setApiKey(created);
|
||||
} catch (createErr: unknown) {
|
||||
setError(createErr instanceof Error ? createErr : new Error(String(createErr)));
|
||||
setError(
|
||||
createErr instanceof Error
|
||||
? createErr
|
||||
: new Error(String(createErr)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
@@ -46,7 +57,9 @@ export function useApiKey() {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.post<ApiKeyResponse>("/api/v1/user/update-api-key");
|
||||
const res = await apiClient.post<ApiKeyResponse>(
|
||||
"/api/v1/user/update-api-key",
|
||||
);
|
||||
const value = typeof res.data === "string" ? res.data : res.data.apiKey;
|
||||
if (typeof value === "string") setApiKey(value);
|
||||
} catch (e: unknown) {
|
||||
@@ -62,7 +75,15 @@ export function useApiKey() {
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
|
||||
|
||||
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
|
||||
return {
|
||||
apiKey,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
refetch: fetchKey,
|
||||
refresh,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
|
||||
+30
-9
@@ -12,13 +12,27 @@ 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 ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +48,8 @@ export function useCredits() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiClient.get<Record<string, unknown>>("/api/v1/credits");
|
||||
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 =
|
||||
@@ -60,7 +75,13 @@ export function useCredits() {
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
|
||||
return { data, isLoading, error, refetch: fetchCredits, hasAttempted } as const;
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchCredits,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
|
||||
+25
-6
@@ -34,12 +34,27 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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" }}>
|
||||
<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>
|
||||
|
||||
@@ -65,9 +80,13 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
{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>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
|
||||
+30
-6
@@ -29,11 +29,26 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
|
||||
<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">
|
||||
@@ -48,7 +63,9 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) => onSelectedCreditsChange(Math.round(value))}
|
||||
onChangeEnd={(value) =>
|
||||
onSelectedCreditsChange(Math.round(value))
|
||||
}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
@@ -69,7 +86,10 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].credits.toLocaleString()} Credits
|
||||
{apiPackages[
|
||||
Math.round(selectedCredits)
|
||||
].credits.toLocaleString()}{" "}
|
||||
Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
@@ -88,7 +108,11 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
|
||||
onClick={() =>
|
||||
onCreditPurchaseClick(
|
||||
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
|
||||
+43
-10
@@ -16,22 +16,45 @@ 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" }}>
|
||||
<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" }}>
|
||||
<div
|
||||
className="flex h-[20rem] mb-4 "
|
||||
style={{ gap: "1rem", overflowX: "auto" }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
@@ -44,7 +67,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
</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")}
|
||||
@@ -61,9 +87,14 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">{t("plan.feature.title", "Feature")}</th>
|
||||
<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">
|
||||
<th
|
||||
key={plan.id}
|
||||
className="text-center p-2 min-w-24 relative"
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
@@ -89,7 +120,9 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, cu
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">{plans[0].features[featureIndex].name}</td>
|
||||
<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 ? (
|
||||
|
||||
@@ -39,9 +39,19 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
|
||||
<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" }}>
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -53,7 +63,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
</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">
|
||||
@@ -75,7 +87,13 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={isCurrentPlan ? "filled" : plan.isContactOnly ? "outline" : "filled"}
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "filled"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
|
||||
@@ -18,8 +18,12 @@ interface CreateSaasConfigNavSectionsOptions {
|
||||
t: TFunction<"translation", undefined>;
|
||||
}
|
||||
|
||||
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex((section) => section.title === "Preferences");
|
||||
function ensurePreferencesSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const preferencesIndex = sections.findIndex(
|
||||
(section) => section.title === "Preferences",
|
||||
);
|
||||
|
||||
if (preferencesIndex === -1) {
|
||||
return [
|
||||
@@ -47,9 +51,13 @@ function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSectio
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
|
||||
function appendDeveloperSection(
|
||||
sections: ConfigNavSection[],
|
||||
): ConfigNavSection[] {
|
||||
const hasDeveloper = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "developer" || item.key === "api-keys"),
|
||||
section.items.some(
|
||||
(item) => item.key === "developer" || item.key === "api-keys",
|
||||
),
|
||||
);
|
||||
|
||||
if (hasDeveloper) {
|
||||
@@ -72,8 +80,13 @@ 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;
|
||||
@@ -127,7 +140,12 @@ export function createSaasConfigNavSections(
|
||||
sections = sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.key === "general" ? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> } : item,
|
||||
item.key === "general"
|
||||
? {
|
||||
...item,
|
||||
component: <GeneralSection hideUpdateSection hideAdminBanner />,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
SegmentedControl,
|
||||
Divider,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Group,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from "@app/constants/signConstants";
|
||||
import {
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
FILE_SWITCH_ACTIVATION_DELAY,
|
||||
} from "@app/constants/signConstants";
|
||||
|
||||
// Import the new reusable components
|
||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||
@@ -37,7 +51,10 @@ type SignatureDrafts = {
|
||||
|
||||
interface SignSettingsProps {
|
||||
parameters: SignParameters;
|
||||
onParameterChange: <K extends keyof SignParameters>(key: K, value: SignParameters[K]) => void;
|
||||
onParameterChange: <K extends keyof SignParameters>(
|
||||
key: K,
|
||||
value: SignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
onActivateDrawMode?: () => void;
|
||||
onActivateSignaturePlacement?: () => void;
|
||||
@@ -53,7 +70,12 @@ interface SignSettingsProps {
|
||||
|
||||
export type SignatureSource = "canvas" | "image" | "text" | "saved";
|
||||
|
||||
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = ["canvas", "image", "text", "saved"];
|
||||
const DEFAULT_SIGNATURE_SOURCES: SignatureSource[] = [
|
||||
"canvas",
|
||||
"image",
|
||||
"text",
|
||||
"saved",
|
||||
];
|
||||
|
||||
const SignSettings = ({
|
||||
parameters,
|
||||
@@ -72,7 +94,10 @@ const SignSettings = ({
|
||||
const { t } = useTranslation();
|
||||
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
const historyApiInstance = historyApiRef.current;
|
||||
const translate = useCallback(
|
||||
(key: string, defaultValue: string, options?: Record<string, unknown>) =>
|
||||
@@ -80,7 +105,8 @@ const SignSettings = ({
|
||||
[t, translationScope],
|
||||
);
|
||||
const effectiveDefaultSource =
|
||||
(defaultSignatureSource && allowedSignatureSources.includes(defaultSignatureSource)
|
||||
(defaultSignatureSource &&
|
||||
allowedSignatureSources.includes(defaultSignatureSource)
|
||||
? defaultSignatureSource
|
||||
: allowedSignatureSources[0]) ?? "text";
|
||||
const canUseSavedLibrary = allowedSignatureSources.includes("saved");
|
||||
@@ -90,11 +116,16 @@ const SignSettings = ({
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
|
||||
const [isPlacementManuallyPaused, setPlacementManuallyPaused] =
|
||||
useState(false);
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
|
||||
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [imageSignatureData, setImageSignatureData] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [signatureDrafts, setSignatureDrafts] = useState<SignatureDrafts>({});
|
||||
const lastSyncedTextDraft = useRef<SignatureDrafts["text"] | null>(null);
|
||||
const lastAppliedPlacementKey = useRef<string | null>(null);
|
||||
@@ -109,21 +140,30 @@ const SignSettings = ({
|
||||
byTypeCounts,
|
||||
storageType,
|
||||
} = useSavedSignatures();
|
||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||
const paramSource = parameters.signatureType as SignatureSource;
|
||||
if (allowedSignatureSources.includes(paramSource)) {
|
||||
return paramSource;
|
||||
}
|
||||
return effectiveDefaultSource;
|
||||
});
|
||||
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<Record<SavedSignatureType, string | null>>({
|
||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(
|
||||
() => {
|
||||
const paramSource = parameters.signatureType as SignatureSource;
|
||||
if (allowedSignatureSources.includes(paramSource)) {
|
||||
return paramSource;
|
||||
}
|
||||
return effectiveDefaultSource;
|
||||
},
|
||||
);
|
||||
const [lastSavedSignatureKeys, setLastSavedSignatureKeys] = useState<
|
||||
Record<SavedSignatureType, string | null>
|
||||
>({
|
||||
canvas: null,
|
||||
image: null,
|
||||
text: null,
|
||||
});
|
||||
|
||||
const buildTextSignatureKey = useCallback(
|
||||
(signerName: string, fontSize: number, fontFamily: string, textColor: string) =>
|
||||
(
|
||||
signerName: string,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
textColor: string,
|
||||
) =>
|
||||
JSON.stringify({
|
||||
signerName: signerName.trim(),
|
||||
fontSize,
|
||||
@@ -191,7 +231,10 @@ const SignSettings = ({
|
||||
(type: SavedSignatureType, explicitKey?: string | null) => {
|
||||
setLastSavedSignatureKeys((prev) => ({
|
||||
...prev,
|
||||
[type]: explicitKey !== undefined ? explicitKey : (signatureKeysByType[type] ?? null),
|
||||
[type]:
|
||||
explicitKey !== undefined
|
||||
? explicitKey
|
||||
: (signatureKeysByType[type] ?? null),
|
||||
}));
|
||||
},
|
||||
[signatureKeysByType],
|
||||
@@ -202,7 +245,11 @@ const SignSettings = ({
|
||||
if (!canvasSignatureData) {
|
||||
return;
|
||||
}
|
||||
const result = await saveSignatureToLibrary({ type: "canvas", dataUrl: canvasSignatureData }, "canvas", scope);
|
||||
const result = await saveSignatureToLibrary(
|
||||
{ type: "canvas", dataUrl: canvasSignatureData },
|
||||
"canvas",
|
||||
scope,
|
||||
);
|
||||
if (result.success) {
|
||||
setLastSavedKeyForType("canvas");
|
||||
}
|
||||
@@ -215,7 +262,11 @@ const SignSettings = ({
|
||||
if (!imageSignatureData) {
|
||||
return;
|
||||
}
|
||||
const result = await saveSignatureToLibrary({ type: "image", dataUrl: imageSignatureData }, "image", scope);
|
||||
const result = await saveSignatureToLibrary(
|
||||
{ type: "image", dataUrl: imageSignatureData },
|
||||
"image",
|
||||
scope,
|
||||
);
|
||||
if (result.success) {
|
||||
setLastSavedKeyForType("image");
|
||||
}
|
||||
@@ -296,7 +347,12 @@ const SignSettings = ({
|
||||
|
||||
const savedKey =
|
||||
signature.type === "text"
|
||||
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
|
||||
? buildTextSignatureKey(
|
||||
signature.signerName,
|
||||
signature.fontSize,
|
||||
signature.fontFamily,
|
||||
signature.textColor,
|
||||
)
|
||||
: signature.dataUrl;
|
||||
setLastSavedKeyForType(signature.type, savedKey);
|
||||
|
||||
@@ -358,17 +414,30 @@ const SignSettings = ({
|
||||
|
||||
let tooltipMessage: string | undefined;
|
||||
if (!isReady) {
|
||||
tooltipMessage = translate("saved.saveUnavailable", "Create a signature first to save it.");
|
||||
tooltipMessage = translate(
|
||||
"saved.saveUnavailable",
|
||||
"Create a signature first to save it.",
|
||||
);
|
||||
} else if (isSaved) {
|
||||
tooltipMessage = translate("saved.noChanges", "Current signature is already saved.");
|
||||
tooltipMessage = translate(
|
||||
"saved.noChanges",
|
||||
"Current signature is already saved.",
|
||||
);
|
||||
} else if (isSavedSignatureLimitReached) {
|
||||
tooltipMessage = translate("saved.limitDescription", "Remove a saved signature before adding new ones (max {{max}}).", {
|
||||
max: maxLimit,
|
||||
});
|
||||
tooltipMessage = translate(
|
||||
"saved.limitDescription",
|
||||
"Remove a saved signature before adding new ones (max {{max}}).",
|
||||
{
|
||||
max: maxLimit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS-specific: Use simple "Save" label for localStorage mode
|
||||
const buttonLabel = storageType === "localStorage" && scope === "personal" ? translate("saved.save", "Save") : label;
|
||||
const buttonLabel =
|
||||
storageType === "localStorage" && scope === "personal"
|
||||
? translate("saved.save", "Save")
|
||||
: label;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
@@ -376,7 +445,9 @@ const SignSettings = ({
|
||||
variant="outline"
|
||||
color={isSaved ? "green" : undefined}
|
||||
onClick={() => onClick(scope)}
|
||||
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
||||
disabled={
|
||||
!isReady || disabled || isSavedSignatureLimitReached || !hasChanges
|
||||
}
|
||||
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
@@ -387,7 +458,14 @@ const SignSettings = ({
|
||||
if (tooltipMessage) {
|
||||
return (
|
||||
<Tooltip label={tooltipMessage}>
|
||||
<Box style={{ display: fullWidth ? "block" : "inline-block", width: fullWidth ? "100%" : undefined }}>{button}</Box>
|
||||
<Box
|
||||
style={{
|
||||
display: fullWidth ? "block" : "inline-block",
|
||||
width: fullWidth ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
{button}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -436,7 +514,11 @@ const SignSettings = ({
|
||||
const hasBothButtons = personalButton && sharedButton;
|
||||
|
||||
if (!hasBothButtons) {
|
||||
return <Box style={{ width: "100%", marginTop: "0.4rem" }}>{personalButton || sharedButton}</Box>;
|
||||
return (
|
||||
<Box style={{ width: "100%", marginTop: "0.4rem" }}>
|
||||
{personalButton || sharedButton}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -455,13 +537,21 @@ const SignSettings = ({
|
||||
if (signatureSource === "saved") {
|
||||
return;
|
||||
}
|
||||
const nextSource = allowedSignatureSources.includes(parameters.signatureType as SignatureSource)
|
||||
const nextSource = allowedSignatureSources.includes(
|
||||
parameters.signatureType as SignatureSource,
|
||||
)
|
||||
? (parameters.signatureType as SignatureSource)
|
||||
: effectiveDefaultSource;
|
||||
if (signatureSource !== nextSource) {
|
||||
setSignatureSource(nextSource);
|
||||
}
|
||||
}, [parameters.signatureType, signatureSource, allowedSignatureSources, effectiveDefaultSource, canUseSavedLibrary]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
signatureSource,
|
||||
allowedSignatureSources,
|
||||
effectiveDefaultSource,
|
||||
canUseSavedLibrary,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
@@ -533,7 +623,10 @@ const SignSettings = ({
|
||||
|
||||
// Directly activate placement on image upload
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -560,7 +653,10 @@ const SignSettings = ({
|
||||
lastAppliedPlacementKey.current = null;
|
||||
// Directly activate placement on signature change
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -571,14 +667,21 @@ const SignSettings = ({
|
||||
[onActivateSignaturePlacement],
|
||||
);
|
||||
|
||||
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
|
||||
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
|
||||
const hasCanvasSignature = useMemo(
|
||||
() => Boolean(canvasSignatureData),
|
||||
[canvasSignatureData],
|
||||
);
|
||||
const hasImageSignature = useMemo(
|
||||
() => Boolean(imageSignatureData),
|
||||
[imageSignatureData],
|
||||
);
|
||||
const hasTextSignature = useMemo(
|
||||
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ""),
|
||||
[parameters.signerName],
|
||||
);
|
||||
|
||||
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||
const hasAnySignature =
|
||||
hasCanvasSignature || hasImageSignature || hasTextSignature;
|
||||
|
||||
const isCurrentTypeReady = useMemo(() => {
|
||||
switch (parameters.signatureType) {
|
||||
@@ -591,7 +694,12 @@ const SignSettings = ({
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
hasCanvasSignature,
|
||||
hasImageSignature,
|
||||
hasTextSignature,
|
||||
]);
|
||||
|
||||
const placementSignatureKey = useMemo(() => {
|
||||
if (!isCurrentTypeReady) {
|
||||
@@ -605,7 +713,8 @@ const SignSettings = ({
|
||||
return isCurrentTypeReady;
|
||||
}, [disabled, isCurrentTypeReady]);
|
||||
|
||||
const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
|
||||
const shouldAutoActivate =
|
||||
shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
|
||||
|
||||
useEffect(() => {
|
||||
setSignatureDrafts((prev) => {
|
||||
@@ -667,7 +776,12 @@ const SignSettings = ({
|
||||
|
||||
return { ...prev, text: nextDraft };
|
||||
});
|
||||
}, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
|
||||
}, [
|
||||
parameters.signerName,
|
||||
parameters.fontSize,
|
||||
parameters.fontFamily,
|
||||
parameters.textColor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType === "text") {
|
||||
@@ -741,7 +855,13 @@ const SignSettings = ({
|
||||
if (parameters.signatureData !== newSignatureData) {
|
||||
onParameterChange("signatureData", newSignatureData);
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]);
|
||||
}, [
|
||||
parameters.signatureType,
|
||||
parameters.signatureData,
|
||||
canvasSignatureData,
|
||||
imageSignatureData,
|
||||
onParameterChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEnablePlacement) {
|
||||
@@ -804,7 +924,13 @@ const SignSettings = ({
|
||||
}
|
||||
|
||||
trigger();
|
||||
}, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]);
|
||||
}, [
|
||||
placementSignatureKey,
|
||||
shouldAutoActivate,
|
||||
shouldEnablePlacement,
|
||||
isPlacementMode,
|
||||
onActivateSignaturePlacement,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (activeFileIndex === previousFileIndexRef.current) {
|
||||
return;
|
||||
@@ -827,7 +953,12 @@ const SignSettings = ({
|
||||
}
|
||||
|
||||
onActivateSignaturePlacement?.();
|
||||
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
|
||||
}, [
|
||||
activeFileIndex,
|
||||
shouldEnablePlacement,
|
||||
signaturesApplied,
|
||||
onActivateSignaturePlacement,
|
||||
]);
|
||||
|
||||
const sourceLabels: Record<SignatureSource, string> = {
|
||||
canvas: translate("type.canvas", "Draw"),
|
||||
@@ -878,7 +1009,11 @@ const SignSettings = ({
|
||||
disabled={disabled}
|
||||
initialSignatureData={canvasSignatureData}
|
||||
/>
|
||||
{renderSaveButtonRow("canvas", hasCanvasSignature, handleSaveCanvasSignature)}
|
||||
{renderSaveButtonRow(
|
||||
"canvas",
|
||||
hasCanvasSignature,
|
||||
handleSaveCanvasSignature,
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -886,8 +1021,15 @@ const SignSettings = ({
|
||||
if (signatureSource === "image") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ImageUploader onImageChange={handleImageChange} disabled={disabled} />
|
||||
{renderSaveButtonRow("image", hasImageSignature, handleSaveImageSignature)}
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{renderSaveButtonRow(
|
||||
"image",
|
||||
hasImageSignature,
|
||||
handleSaveImageSignature,
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -900,7 +1042,9 @@ const SignSettings = ({
|
||||
fontSize={parameters.fontSize || 16}
|
||||
onFontSizeChange={(size) => onParameterChange("fontSize", size)}
|
||||
fontFamily={parameters.fontFamily || "Helvetica"}
|
||||
onFontFamilyChange={(family) => onParameterChange("fontFamily", family)}
|
||||
onFontFamilyChange={(family) =>
|
||||
onParameterChange("fontFamily", family)
|
||||
}
|
||||
textColor={parameters.textColor || "#000000"}
|
||||
onTextColorChange={(color) => onParameterChange("textColor", color)}
|
||||
disabled={disabled}
|
||||
@@ -908,13 +1052,19 @@ const SignSettings = ({
|
||||
placeholder={translate("text.placeholder", "Enter text")}
|
||||
fontLabel={translate("text.fontLabel", "Font")}
|
||||
fontSizeLabel={translate("text.fontSizeLabel", "Font size")}
|
||||
fontSizePlaceholder={translate("text.fontSizePlaceholder", "Type or select font size (8-200)")}
|
||||
fontSizePlaceholder={translate(
|
||||
"text.fontSizePlaceholder",
|
||||
"Type or select font size (8-200)",
|
||||
)}
|
||||
onAnyChange={() => {
|
||||
setPlacementManuallyPaused(false);
|
||||
lastAppliedPlacementKey.current = null;
|
||||
// Directly activate placement on text changes
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => onActivateSignaturePlacement?.(), PLACEMENT_ACTIVATION_DELAY);
|
||||
window.setTimeout(
|
||||
() => onActivateSignaturePlacement?.(),
|
||||
PLACEMENT_ACTIVATION_DELAY,
|
||||
);
|
||||
} else {
|
||||
onActivateSignaturePlacement?.();
|
||||
}
|
||||
@@ -927,7 +1077,10 @@ const SignSettings = ({
|
||||
|
||||
const placementInstructions = () => {
|
||||
if (signatureSource === "saved") {
|
||||
return translate("instructions.saved", "Select a saved signature above, then click anywhere on the PDF to place it.");
|
||||
return translate(
|
||||
"instructions.saved",
|
||||
"Select a saved signature above, then click anywhere on the PDF to place it.",
|
||||
);
|
||||
}
|
||||
if (parameters.signatureType === "canvas") {
|
||||
return translate(
|
||||
@@ -936,7 +1089,10 @@ const SignSettings = ({
|
||||
);
|
||||
}
|
||||
if (parameters.signatureType === "image") {
|
||||
return translate("instructions.image", "After uploading your signature image, click anywhere on the PDF to place it.");
|
||||
return translate(
|
||||
"instructions.image",
|
||||
"After uploading your signature image, click anywhere on the PDF to place it.",
|
||||
);
|
||||
}
|
||||
return translate(
|
||||
"instructions.text",
|
||||
@@ -952,12 +1108,18 @@ const SignSettings = ({
|
||||
: translate("instructions.paused", "Placement paused"),
|
||||
message: isPlacementMode
|
||||
? placementInstructions()
|
||||
: translate("instructions.resumeHint", "Resume placement to click and add your signature."),
|
||||
: translate(
|
||||
"instructions.resumeHint",
|
||||
"Resume placement to click and add your signature.",
|
||||
),
|
||||
}
|
||||
: {
|
||||
color: "yellow",
|
||||
title: translate("instructions.title", "How to add your signature"),
|
||||
message: translate("instructions.noSignature", "Create a signature above to enable placement tools."),
|
||||
message: translate(
|
||||
"instructions.noSignature",
|
||||
"Create a signature above to enable placement tools.",
|
||||
),
|
||||
};
|
||||
|
||||
const handlePausePlacement = () => {
|
||||
@@ -1007,7 +1169,11 @@ const SignSettings = ({
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||
<LocalIcon
|
||||
icon="material-symbols:pause-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate("mode.pause", "Pause placement")}
|
||||
</Text>
|
||||
@@ -1020,7 +1186,9 @@ const SignSettings = ({
|
||||
size="lg"
|
||||
aria-label={translate("mode.resume", "Resume placement")}
|
||||
onClick={handleResumePlacement}
|
||||
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
|
||||
disabled={
|
||||
disabled || !isCurrentTypeReady || !onActivateSignaturePlacement
|
||||
}
|
||||
style={{
|
||||
width: "auto",
|
||||
paddingInline: "0.75rem",
|
||||
@@ -1029,7 +1197,11 @@ const SignSettings = ({
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
||||
<LocalIcon
|
||||
icon="material-symbols:play-arrow-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate("mode.resume", "Resume placement")}
|
||||
</Text>
|
||||
@@ -1042,13 +1214,18 @@ const SignSettings = ({
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{translate("step.createDesc", "Choose how you want to create the signature")}
|
||||
{translate(
|
||||
"step.createDesc",
|
||||
"Choose how you want to create the signature",
|
||||
)}
|
||||
</Text>
|
||||
{sourceOptions.length > 1 && (
|
||||
<SegmentedControl
|
||||
value={signatureSource}
|
||||
fullWidth
|
||||
onChange={(value) => handleSignatureSourceChange(value as SignatureSource)}
|
||||
onChange={(value) =>
|
||||
handleSignatureSourceChange(value as SignatureSource)
|
||||
}
|
||||
data={sourceOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const devApiLink = "https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/";
|
||||
export const devApiLink =
|
||||
"https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/";
|
||||
|
||||
@@ -75,7 +75,11 @@ export function useAutoAnonymousAuth() {
|
||||
const triggerAnonymousAuth = useCallback(async () => {
|
||||
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");
|
||||
|
||||
@@ -89,13 +93,18 @@ export function useAutoAnonymousAuth() {
|
||||
}
|
||||
|
||||
console.log("[useAutoAnonymousAuth] anonymous auth complete");
|
||||
setState((prev) => ({ ...prev, isAutoAuthenticating: false, shouldTriggerAutoAuth: false }));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAutoAuthenticating: false,
|
||||
shouldTriggerAutoAuth: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
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]);
|
||||
@@ -127,7 +136,11 @@ export function useAutoAnonymousAuth() {
|
||||
// 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]);
|
||||
|
||||
|
||||
@@ -3,5 +3,7 @@ import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
export function useConfigButtonIcon(): React.ReactNode {
|
||||
const { profilePictureUrl } = useAuth();
|
||||
return profilePictureUrl ? <Avatar src={profilePictureUrl} radius="xl" size={24} /> : null;
|
||||
return profilePictureUrl ? (
|
||||
<Avatar src={profilePictureUrl} radius="xl" size={24} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ 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 { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
|
||||
useCredits();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checkCredits = useCallback(
|
||||
@@ -32,16 +33,30 @@ export function useCreditCheck(operationType?: string, _endpoint?: string) {
|
||||
const error = t(
|
||||
"insufficientCredits",
|
||||
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
|
||||
{ requiredCredits, currentBalance: creditCheck.currentBalance, shortfall },
|
||||
{
|
||||
requiredCredits,
|
||||
currentBalance: creditCheck.currentBalance,
|
||||
shortfall,
|
||||
},
|
||||
);
|
||||
const notice = t(
|
||||
"noticeTopUpOrPlan",
|
||||
"Not enough credits, please top up or upgrade to a plan",
|
||||
);
|
||||
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],
|
||||
[
|
||||
hasSufficientCredits,
|
||||
isPro,
|
||||
creditBalance,
|
||||
refreshCredits,
|
||||
operationType,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useAuth } from "@app/auth/UseSession";
|
||||
* 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
|
||||
|
||||
@@ -38,7 +38,8 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
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);
|
||||
@@ -68,8 +69,12 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [endpointDetails, setEndpointDetails] = useState<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchAllEndpointStatuses = async (force = false) => {
|
||||
@@ -77,7 +82,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
// 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];
|
||||
@@ -87,7 +94,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 }));
|
||||
@@ -106,9 +116,13 @@ 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];
|
||||
@@ -118,7 +132,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 }));
|
||||
@@ -129,7 +146,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(",");
|
||||
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
);
|
||||
const statusMap = response.data;
|
||||
@@ -153,7 +172,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(fullStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details }));
|
||||
@@ -161,35 +183,56 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
} 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 };
|
||||
const optimisticDetails: EndpointAvailabilityDetails = {
|
||||
enabled: true,
|
||||
reason: null,
|
||||
};
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
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(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
const optimisticDetails: EndpointAvailabilityDetails = {
|
||||
enabled: true,
|
||||
reason: null,
|
||||
};
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[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 }));
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SaaS no-op: Supabase handles auth, no JWT event listener needed,
|
||||
* and config is fetched on all pages (401 handling covers unauthenticated state).
|
||||
*/
|
||||
export function useJwtConfigSync(_fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
|
||||
export function useJwtConfigSync(_fetchConfig: (force?: boolean) => void): {
|
||||
isAuthPage: boolean;
|
||||
} {
|
||||
return { isAuthPage: false };
|
||||
}
|
||||
|
||||
@@ -57,14 +57,22 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPlanId, setCurrentPlanId] = useState<string>("free");
|
||||
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { unit_amount: number; currency: string }>>(new Map());
|
||||
const [dynamicPrices, setDynamicPrices] = useState<
|
||||
Map<string, { unit_amount: number; currency: string }>
|
||||
>(new Map());
|
||||
|
||||
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 }>;
|
||||
@@ -73,10 +81,14 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
body: { lookup_keys: lookupKeys, currency },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || !data.prices || !data.missing) throw new Error("No pricing data returned");
|
||||
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 }>();
|
||||
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",
|
||||
@@ -104,7 +116,9 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
setDynamicPrices(priceMap);
|
||||
} catch (err) {
|
||||
console.error("Error fetching pricing:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch pricing data");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to fetch pricing data",
|
||||
);
|
||||
// continue with static prices if needed
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -126,18 +140,35 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
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.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.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,
|
||||
price: dynamicPrices.get("pro")
|
||||
? dynamicPrices.get("pro")!.unit_amount / 100
|
||||
: 8,
|
||||
currency: dynamicPrices.get("pro")
|
||||
? getCurrencySymbol(dynamicPrices.get("pro")!.currency)
|
||||
: getCurrencySymbol(currency),
|
||||
@@ -149,12 +180,27 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
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.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.priority", "Priority Support"),
|
||||
included: false,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -170,12 +216,27 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
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.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.priority", "Priority Support"),
|
||||
included: true,
|
||||
},
|
||||
{
|
||||
name: t("plan.feature.customPricing", "Custom Pricing"),
|
||||
included: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -184,7 +245,9 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
const getPriceInfo = (key: string, fallbackPrice: number) => {
|
||||
const priceObj = dynamicPrices.get(key);
|
||||
const price = priceObj ? priceObj.unit_amount / 100 : fallbackPrice;
|
||||
const currencySymbol = priceObj ? getCurrencySymbol(priceObj.currency) : getCurrencySymbol(currency);
|
||||
const currencySymbol = priceObj
|
||||
? getCurrencySymbol(priceObj.currency)
|
||||
: getCurrencySymbol(currency);
|
||||
return { price, currencySymbol };
|
||||
};
|
||||
|
||||
@@ -199,9 +262,15 @@ export const usePlans = (currency: string = "gbp") => {
|
||||
const mediumPerCredit = mediumPrice.price / 1000;
|
||||
const largePerCredit = largePrice.price / 5000;
|
||||
|
||||
const smallDiscount = Math.round((1 - smallPerCredit / xsmallPerCredit) * 100);
|
||||
const mediumDiscount = Math.round((1 - mediumPerCredit / xsmallPerCredit) * 100);
|
||||
const largeDiscount = Math.round((1 - largePerCredit / xsmallPerCredit) * 100);
|
||||
const smallDiscount = Math.round(
|
||||
(1 - smallPerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
const mediumDiscount = Math.round(
|
||||
(1 - mediumPerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
const largeDiscount = Math.round(
|
||||
(1 - largePerCredit / xsmallPerCredit) * 100,
|
||||
);
|
||||
|
||||
const apiPackages: ApiPackage[] = [
|
||||
{
|
||||
|
||||
@@ -38,7 +38,10 @@ export default function AuthCallback() {
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error;
|
||||
console.error("[Auth Callback Debug] OAuth error:", { error, errorDescription });
|
||||
console.error("[Auth Callback Debug] OAuth error:", {
|
||||
error,
|
||||
errorDescription,
|
||||
});
|
||||
|
||||
setState({
|
||||
status: "error",
|
||||
@@ -60,10 +63,14 @@ export default function AuthCallback() {
|
||||
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",
|
||||
@@ -92,7 +99,9 @@ export default function AuthCallback() {
|
||||
});
|
||||
} 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();
|
||||
|
||||
@@ -167,7 +176,9 @@ export default function AuthCallback() {
|
||||
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}>
|
||||
<div
|
||||
className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={withBasePath("/branding/StirlingPDFLogoNoTextDark.svg")}
|
||||
@@ -175,7 +186,9 @@ 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{getTitle()}
|
||||
</h1>
|
||||
<p className={`text-base ${getStatusColor()}`}>{state.message}</p>
|
||||
|
||||
{state.status === "processing" && (
|
||||
@@ -205,7 +218,9 @@ export default function AuthCallback() {
|
||||
|
||||
{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>
|
||||
|
||||
@@ -10,15 +10,25 @@ import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner";
|
||||
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth();
|
||||
const { isAutoAuthenticating, autoAuthError, shouldTriggerAutoAuth } = useAutoAnonymousAuth();
|
||||
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:", {
|
||||
pathname: location.pathname,
|
||||
@@ -32,12 +42,25 @@ export default function Landing() {
|
||||
|
||||
// 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)) {
|
||||
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>
|
||||
);
|
||||
@@ -56,7 +79,9 @@ export default function Landing() {
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -48,9 +48,15 @@ export default function Login() {
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t("login.title", "Sign in")} - Stirling PDF`,
|
||||
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
|
||||
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)"),
|
||||
ogDescription: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`,
|
||||
});
|
||||
@@ -60,7 +66,9 @@ export default function Login() {
|
||||
return <LoggedInState />;
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: "github" | "google" | "apple" | "azure") => {
|
||||
const signInWithProvider = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
@@ -68,7 +76,10 @@ export default function Login() {
|
||||
const redirectTo = absoluteWithBasePath("/auth/callback");
|
||||
console.log(`[Login] Signing in with ${provider}`);
|
||||
|
||||
const oauthOptions: { redirectTo: string; queryParams?: Record<string, string> } = { redirectTo };
|
||||
const oauthOptions: {
|
||||
redirectTo: string;
|
||||
queryParams?: Record<string, string>;
|
||||
} = { redirectTo };
|
||||
if (provider === "apple") {
|
||||
oauthOptions.queryParams = { scope: "email name" };
|
||||
} else if (provider === "azure") {
|
||||
@@ -87,11 +98,17 @@ export default function Login() {
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error);
|
||||
setError(t("login.failedToSignIn", { provider, message: error.message }));
|
||||
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" }));
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
@@ -123,7 +140,11 @@ export default function Login() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error]:", err);
|
||||
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
@@ -159,7 +180,11 @@ export default function Login() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error:", err);
|
||||
setError(t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }));
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
@@ -178,17 +203,25 @@ export default function Login() {
|
||||
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();
|
||||
|
||||
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" }));
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
@@ -196,15 +229,26 @@ export default function Login() {
|
||||
|
||||
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">
|
||||
@@ -226,33 +270,55 @@ 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">
|
||||
<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">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("signup.signUp", "Sign up")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@ export default function ResetPassword() {
|
||||
|
||||
// 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 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");
|
||||
@@ -48,7 +50,8 @@ export default function ResetPassword() {
|
||||
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);
|
||||
@@ -71,7 +74,11 @@ export default function ResetPassword() {
|
||||
|
||||
// Clear sensitive tokens from the URL hash
|
||||
if (hash.includes("access_token") || hashError) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + (inRecovery ? "?type=recovery" : ""));
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + (inRecovery ? "?type=recovery" : ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for Supabase auth state changes to confirm recovery state
|
||||
@@ -95,9 +102,12 @@ export default function ResetPassword() {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const redirectTo = absoluteWithBasePath("/auth/reset?type=recovery");
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo,
|
||||
});
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(
|
||||
email.trim(),
|
||||
{
|
||||
redirectTo,
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
@@ -130,7 +140,12 @@ export default function ResetPassword() {
|
||||
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("");
|
||||
@@ -143,7 +158,9 @@ export default function ResetPassword() {
|
||||
if (sessionData.session) {
|
||||
navigate("/");
|
||||
} else {
|
||||
const query = derivedEmail ? `?email=${encodeURIComponent(derivedEmail)}` : "";
|
||||
const query = derivedEmail
|
||||
? `?email=${encodeURIComponent(derivedEmail)}`
|
||||
: "";
|
||||
navigate(`/login${query}`);
|
||||
}
|
||||
}, 2000);
|
||||
@@ -157,7 +174,9 @@ export default function ResetPassword() {
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={
|
||||
isRecovery ? t("login.resetYourPassword", "Reset your password") : t("login.forgotPassword", "Forgot your password?")
|
||||
isRecovery
|
||||
? t("login.resetYourPassword", "Reset your password")
|
||||
: t("login.forgotPassword", "Forgot your password?")
|
||||
}
|
||||
/>
|
||||
{!didUpdate && <SuccessMessage success={success} />}
|
||||
@@ -166,7 +185,13 @@ export default function ResetPassword() {
|
||||
{didUpdate ? (
|
||||
<>
|
||||
<SuccessMessage
|
||||
success={success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
|
||||
success={
|
||||
success ||
|
||||
t(
|
||||
"login.passwordUpdatedSuccess",
|
||||
"Your password has been updated successfully.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavigationLink
|
||||
onClick={() => navigate("/login")}
|
||||
@@ -213,7 +238,9 @@ 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")}
|
||||
|
||||
@@ -17,7 +17,10 @@ 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 {
|
||||
useSignupFormValidation,
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
|
||||
export default function Signup() {
|
||||
@@ -46,9 +49,15 @@ export default function Signup() {
|
||||
// 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 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") {
|
||||
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 });
|
||||
@@ -65,7 +74,9 @@ export default function Signup() {
|
||||
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();
|
||||
@@ -76,7 +87,9 @@ export default function Signup() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Signup] Anonymous sign-in unexpected error:", err);
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`);
|
||||
setError(
|
||||
`Unexpected error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSigningUp(false);
|
||||
}
|
||||
@@ -87,9 +100,15 @@ export default function Signup() {
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
|
||||
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
|
||||
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)"),
|
||||
ogDescription: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`,
|
||||
});
|
||||
@@ -98,7 +117,12 @@ export default function Signup() {
|
||||
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 || {});
|
||||
@@ -130,19 +154,29 @@ export default function Signup() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Signup] Unexpected error:", 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderSignIn = async (provider: "github" | "google" | "apple" | "azure") => {
|
||||
const handleProviderSignIn = async (
|
||||
provider: "github" | "google" | "apple" | "azure",
|
||||
) => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
@@ -156,12 +190,20 @@ export default function Signup() {
|
||||
|
||||
{/* OAuth first */}
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<OAuthButtons onProviderClick={handleProviderSignIn} isSubmitting={isSigningUp} layout="fullwidth" />
|
||||
<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} />
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Use Email Instead button (toggles email form) */}
|
||||
@@ -195,18 +237,30 @@ 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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.logIn", "Log In")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
justify-content: center;
|
||||
background-color: var(--auth-bg-color-light-only);
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,19 @@ interface AuthLayoutProps {
|
||||
isEmailFormExpanded?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) {
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
isEmailFormExpanded = false,
|
||||
}: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const leftPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||
const imageSlides = useMemo(
|
||||
() => buildLoginSlides(logoVariant, t),
|
||||
[logoVariant, t],
|
||||
);
|
||||
const isOverflowing = useIsOverflowing(leftPanelRef);
|
||||
|
||||
// Use either overflow detection or email form expansion to determine scrollable state
|
||||
@@ -27,7 +33,9 @@ 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");
|
||||
@@ -35,7 +43,10 @@ export default function AuthLayout({ children, isEmailFormExpanded = false }: Au
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -63,17 +74,33 @@ export default function AuthLayout({ children, isEmailFormExpanded = false }: Au
|
||||
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>
|
||||
{!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>
|
||||
|
||||
@@ -8,7 +8,11 @@ interface GuestSignInButtonProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function GuestSignInButton({ label, onClick, disabled }: GuestSignInButtonProps) {
|
||||
export default function GuestSignInButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: GuestSignInButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -51,7 +51,9 @@ export default function EmailPasswordForm({
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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 && (
|
||||
@@ -69,12 +71,18 @@ export default function EmailPasswordForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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>
|
||||
|
||||
@@ -47,7 +47,11 @@ export default function MagicLinkForm({
|
||||
onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
|
||||
className="auth-input"
|
||||
/>
|
||||
<button onClick={onSubmit} disabled={isSubmitting || !magicLinkEmail} className="auth-magic-button">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !magicLinkEmail}
|
||||
className="auth-magic-button"
|
||||
>
|
||||
{isSubmitting ? t("login.sending") : t("login.sendMagicLink")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,11 @@ export default function OAuthButtons({
|
||||
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")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
@@ -54,7 +58,11 @@ export default function OAuthButtons({
|
||||
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")}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
|
||||
@@ -31,7 +31,9 @@ export const useAuthService = () => {
|
||||
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") },
|
||||
|
||||
@@ -11,16 +11,22 @@ interface DeleteUserResponse {
|
||||
stripe_redaction_job_id?: string | null;
|
||||
}
|
||||
|
||||
export async function deleteCurrentAccount(options?: DeleteAccountOptions): Promise<void> {
|
||||
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>("delete-user", {
|
||||
body: {
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
export async function deleteCurrentAccount(
|
||||
options?: DeleteAccountOptions,
|
||||
): Promise<void> {
|
||||
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>(
|
||||
"delete-user",
|
||||
{
|
||||
body: {
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,14 +197,22 @@ 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");
|
||||
|
||||
// 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
|
||||
|
||||
@@ -8,7 +8,9 @@ import { openPlanSettings } from "@app/utils/appSettings";
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
|
||||
// Function to set the global credit update callback
|
||||
export const setGlobalCreditUpdateCallback = (callback: (credits: number) => void) => {
|
||||
export const setGlobalCreditUpdateCallback = (
|
||||
callback: (credits: number) => void,
|
||||
) => {
|
||||
globalCreditUpdateCallback = callback;
|
||||
};
|
||||
|
||||
@@ -18,8 +20,14 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
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 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);
|
||||
@@ -65,7 +73,10 @@ apiClient.interceptors.request.use(
|
||||
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 role =
|
||||
(payload?.["role"] as string) ||
|
||||
(payload?.["user_role"] as string) ||
|
||||
undefined;
|
||||
const aud = payload?.["aud"] as string | undefined;
|
||||
const isAnon = role === "anon" || aud === "anon";
|
||||
|
||||
@@ -73,10 +84,20 @@ apiClient.interceptors.request.use(
|
||||
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] 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);
|
||||
@@ -105,27 +126,44 @@ apiClient.interceptors.response.use(
|
||||
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);
|
||||
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) {
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isPublicEndpoint
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
@@ -146,7 +184,8 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// Only redirect to login for protected endpoints, not public ones
|
||||
const isPublicEndpoint =
|
||||
originalRequest.url?.includes("/api/v1/config/") || originalRequest.url?.includes("/api/v1/info/");
|
||||
originalRequest.url?.includes("/api/v1/config/") ||
|
||||
originalRequest.url?.includes("/api/v1/info/");
|
||||
|
||||
if (!isPublicEndpoint) {
|
||||
// Redirect to login only for protected endpoints
|
||||
@@ -167,7 +206,9 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
} else {
|
||||
// No session exists, only redirect if not already on login page
|
||||
console.debug("[API Client] No session to refresh, 401 on protected endpoint");
|
||||
console.debug(
|
||||
"[API Client] No session to refresh, 401 on protected endpoint",
|
||||
);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
@@ -179,7 +220,10 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -12,7 +12,10 @@ 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;
|
||||
@@ -67,7 +70,9 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -102,7 +107,10 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
|
||||
// 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) => {
|
||||
@@ -124,7 +132,10 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to download and optimize avatar:", error);
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to download and optimize avatar:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -134,18 +145,23 @@ export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
* @param userId User ID
|
||||
* @param blob Optimized avatar blob
|
||||
*/
|
||||
export async function uploadAvatarToStorage(userId: string, blob: Blob): Promise<void> {
|
||||
export async function uploadAvatarToStorage(
|
||||
userId: string,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profilePath = `${userId}/avatar`;
|
||||
|
||||
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;
|
||||
@@ -163,17 +179,31 @@ 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");
|
||||
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);
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to fetch profile picture metadata:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -191,7 +221,9 @@ export async function getProfilePictureMetadata(userId: string): Promise<Profile
|
||||
*/
|
||||
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(
|
||||
@@ -206,8 +238,13 @@ export async function updateProfilePictureMetadata(
|
||||
|
||||
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.");
|
||||
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;
|
||||
@@ -238,12 +275,16 @@ export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
// 0. Check client-side session cache first (prevent repeated attempts)
|
||||
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:", {
|
||||
minutesAgo: minutesSinceLastAttempt.toFixed(1),
|
||||
lastSuccess: cached.success,
|
||||
});
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - already attempted in this session:",
|
||||
{
|
||||
minutesAgo: minutesSinceLastAttempt.toFixed(1),
|
||||
lastSuccess: cached.success,
|
||||
},
|
||||
);
|
||||
return cached.success;
|
||||
}
|
||||
}
|
||||
@@ -255,11 +296,15 @@ export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
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");
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - not an OAuth provider with avatar support",
|
||||
);
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
@@ -277,13 +322,17 @@ export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
// 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 daysSinceSync =
|
||||
(Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
|
||||
console.debug("[Avatar Sync] Skipping sync - synced recently:", {
|
||||
daysSinceSync: daysSinceSync.toFixed(1),
|
||||
threshold: SYNC_INTERVAL_DAYS,
|
||||
});
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true });
|
||||
sessionSyncCache.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -302,7 +351,12 @@ export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
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);
|
||||
|
||||
@@ -28,7 +28,9 @@ 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",
|
||||
|
||||
@@ -107,7 +107,9 @@ 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;
|
||||
},
|
||||
|
||||
@@ -162,18 +164,27 @@ export const userManagementService = {
|
||||
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("enabled", enabled.toString());
|
||||
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
await apiClient.post(
|
||||
`/api/v1/user/admin/changeUserEnabled/${username}`,
|
||||
formData,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a user (admin only)
|
||||
*/
|
||||
async deleteUser(user: User, options?: { notifyUser?: boolean }): Promise<void> {
|
||||
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", {
|
||||
@@ -203,9 +214,13 @@ export const userManagementService = {
|
||||
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;
|
||||
},
|
||||
@@ -213,7 +228,9 @@ export const userManagementService = {
|
||||
/**
|
||||
* Generate an invite link (admin only)
|
||||
*/
|
||||
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
|
||||
async generateInviteLink(
|
||||
data: InviteLinkRequest,
|
||||
): Promise<InviteLinkResponse> {
|
||||
const formData = new FormData();
|
||||
// Only append email if it's provided and not empty
|
||||
if (data.email && data.email.trim()) {
|
||||
@@ -230,9 +247,13 @@ export const userManagementService = {
|
||||
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;
|
||||
},
|
||||
@@ -241,7 +262,9 @@ 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;
|
||||
},
|
||||
|
||||
@@ -258,7 +281,9 @@ 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,7 +35,9 @@ export const synchronizeUserUpgrade = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,15 @@ Object.defineProperty(global, "localStorage", { value: localStorageMock });
|
||||
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() } } }),
|
||||
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() } } }),
|
||||
},
|
||||
},
|
||||
debugAuthEvents: vi.fn(),
|
||||
@@ -97,19 +103,27 @@ for (let i = 0; i < 32; i++) {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: {
|
||||
subtle: {
|
||||
digest: vi.fn().mockImplementation(async (_algorithm: string, _data: BufferSource) => {
|
||||
// Always return the mock hash buffer regardless of input
|
||||
return mockHashBuffer.slice();
|
||||
}),
|
||||
digest: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_algorithm: string, _data: BufferSource) => {
|
||||
// Always return the mock hash buffer regardless of input
|
||||
return mockHashBuffer.slice();
|
||||
}),
|
||||
},
|
||||
getRandomValues: vi.fn().mockImplementation(<T extends ArrayBufferView>(array: T): T => {
|
||||
// Mock getRandomValues if needed
|
||||
const view = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
view[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
getRandomValues: vi
|
||||
.fn()
|
||||
.mockImplementation(<T extends ArrayBufferView>(array: T): T => {
|
||||
// Mock getRandomValues if needed
|
||||
const view = new Uint8Array(
|
||||
array.buffer,
|
||||
array.byteOffset,
|
||||
array.byteLength,
|
||||
);
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
view[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
} as unknown as Crypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
||||
@@ -12,7 +12,9 @@ export function openAppSettings(targetKey?: NavKey, notice?: string) {
|
||||
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
|
||||
|
||||
@@ -17,7 +17,10 @@ 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();
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export function isToolRoute(pathname: string): boolean {
|
||||
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;
|
||||
if (p.endsWith("/") && URL_TO_TOOL_MAP[p.slice(0, -1)] !== undefined)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user