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:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
+3 -1
View File
@@ -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 />
+150 -40
View File
@@ -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,
);
});
}
}
+11 -3
View File
@@ -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>
)}
@@ -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;
@@ -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;
@@ -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 && (
@@ -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>
@@ -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}
/>
)}
+2 -1
View File
@@ -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;
}
+19 -4
View File
@@ -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 };
+9 -1
View File
@@ -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
+61 -18
View File
@@ -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 }));
+3 -1
View File
@@ -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 };
}
+94 -25
View File
@@ -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[] = [
{
+22 -7
View File
@@ -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>
+33 -8
View File
@@ -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)
+86 -20
View File
@@ -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>
+38 -11
View File
@@ -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")}
+70 -16
View File
@@ -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") },
+12 -6
View File
@@ -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);
}
}
+10 -2
View File
@@ -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
+58 -14
View File
@@ -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;
+82 -28
View File
@@ -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;
},
};
+3 -1
View File
@@ -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");
}
+29 -15
View File
@@ -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,
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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();
+2 -1
View File
@@ -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;
}