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

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

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

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useAuth } from '@app/auth/UseSession';
import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal';
import { useEffect, useState } from "react";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useOnboarding } from "@app/contexts/OnboardingContext";
import { useAuth } from "@app/auth/UseSession";
import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal";
const STORAGE_KEY = 'saas_onboarding_seen';
const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
const STORAGE_KEY = "saas_onboarding_seen";
const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active";
/**
* SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
@@ -21,10 +21,10 @@ export default function OnboardingBootstrap() {
// Start polling when user logs in
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true";
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
console.debug('[Onboarding] Starting poll for trial data');
console.debug("[Onboarding] Starting poll for trial data");
setIsPolling(true);
setPollAttempts(0);
}
@@ -38,7 +38,7 @@ export default function OnboardingBootstrap() {
const timer = setTimeout(async () => {
const newAttempts = pollAttempts + 1;
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
console.debug("[Onboarding] Polling for trial data, attempt:", newAttempts);
await refreshTrialStatus();
setPollAttempts(newAttempts);
@@ -58,12 +58,12 @@ export default function OnboardingBootstrap() {
const maxAttempts = 10;
if (hasData || pollAttempts >= maxAttempts) {
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
console.debug("[Onboarding] Trial data ready or timeout, showing modal", {
hasData,
hasProStatus,
attempts: pollAttempts,
trialStatus,
isPro
isPro,
});
setIsPolling(false);
setShowModal(true);
@@ -71,7 +71,7 @@ export default function OnboardingBootstrap() {
}, [isPolling, trialStatus, isPro, pollAttempts]);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, 'true');
localStorage.setItem(STORAGE_KEY, "true");
setShowModal(false);
};
@@ -79,8 +79,8 @@ export default function OnboardingBootstrap() {
useEffect(() => {
// Ensure tool panel preference is set so tours are never deferred.
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
updatePreference("toolPanelModePromptSeen", true);
updatePreference("hasSelectedToolPanelMode", true);
}
// Clear any lingering deferred tour requests.
@@ -89,15 +89,15 @@ export default function OnboardingBootstrap() {
// In SaaS, skip the core intro onboarding entirely.
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
updatePreference("hasSeenIntroOnboarding", true);
}
// Also mark completed to avoid follow-up banners/modals.
if (!preferences.hasCompletedOnboarding) {
updatePreference('hasCompletedOnboarding', true);
updatePreference("hasCompletedOnboarding", true);
}
// Also clear any session flag that might mark onboarding as active.
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
}
}, [
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
import { useEffect, useState } from "react";
import { useAuth } from "@app/auth/UseSession";
import { TrialExpiredModal } from "@app/components/shared/TrialExpiredModal";
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
/**
* Bootstrap component that shows the trial expired modal when a user's trial has ended
@@ -16,7 +16,7 @@ export default function TrialExpiredBootstrap() {
// Close modal if user logs out or session expires
if (!user) {
if (showModal) {
console.debug('[TrialExpired] User logged out, closing modal');
console.debug("[TrialExpired] User logged out, closing modal");
setShowModal(false);
}
if (checkoutOpened) {
@@ -32,30 +32,28 @@ export default function TrialExpiredBootstrap() {
// Build localStorage key unique to this user
const storageKey = `trialExpiredModalShown_${user.id}`;
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
const hasSeenModal = localStorage.getItem(storageKey) === "true";
// If user is currently trialing, clear any previous "seen" flag
// This handles the edge case where a user might re-enter a trial
if (trialStatus.isTrialing) {
if (hasSeenModal) {
console.debug('[TrialExpired] User is trialing, clearing seen flag');
console.debug("[TrialExpired] User is trialing, clearing seen flag");
localStorage.removeItem(storageKey);
}
return;
}
// Check if all conditions are met to show the modal
const isExpired =
trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled';
const isExpired = trialStatus.status === "incomplete_expired" || trialStatus.status === "canceled";
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
const wasDowngraded = !isPro;
const trialEndedRecently = trialStatus.daysRemaining === 0;
const shouldShowModal =
isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
const shouldShowModal = isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
if (shouldShowModal) {
console.debug('[TrialExpired] Showing trial expired modal', {
console.debug("[TrialExpired] Showing trial expired modal", {
status: trialStatus.status,
daysRemaining: trialStatus.daysRemaining,
hasPaymentMethod: trialStatus.hasPaymentMethod,
@@ -69,36 +67,32 @@ export default function TrialExpiredBootstrap() {
const handleClose = () => {
if (user) {
const storageKey = `trialExpiredModalShown_${user.id}`;
localStorage.setItem(storageKey, 'true');
console.debug('[TrialExpired] Modal dismissed, marking as seen');
localStorage.setItem(storageKey, "true");
console.debug("[TrialExpired] Modal dismissed, marking as seen");
}
setShowModal(false);
};
const handleSubscribe = () => {
console.debug('[TrialExpired] User clicked Subscribe to Pro');
console.debug("[TrialExpired] User clicked Subscribe to Pro");
setCheckoutOpened(true);
};
const handleCheckoutSuccess = () => {
console.debug('[TrialExpired] Subscription successful, refreshing page');
console.debug("[TrialExpired] Subscription successful, refreshing page");
// Close modal and refresh to update subscription status
handleClose();
window.location.reload();
};
const handleCheckoutClose = () => {
console.debug('[TrialExpired] Checkout closed');
console.debug("[TrialExpired] Checkout closed");
setCheckoutOpened(false);
};
return (
<>
<TrialExpiredModal
opened={showModal && !checkoutOpened}
onClose={handleClose}
onSubscribe={handleSubscribe}
/>
<TrialExpiredModal opened={showModal && !checkoutOpened} onClose={handleClose} onSubscribe={handleSubscribe} />
{user && (
<StripeCheckout
@@ -109,7 +103,7 @@ export default function TrialExpiredBootstrap() {
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
onError={(error) => console.error("[TrialExpired] Checkout error:", error)}
isTrialConversion={false} // Trial already ended, so this is not a conversion
/>
)}
@@ -4,9 +4,9 @@
right: 4.5rem;
z-index: 1000;
background: var(--modal-content-bg, #111418);
border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08));
border: 1px solid var(--api-keys-card-border, rgba(255, 255, 255, 0.08));
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.35);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
max-width: 30rem;
width: 30rem;
@@ -57,7 +57,7 @@
}
.guest-banner-dismiss:hover {
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
background: var(--mantine-color-gray-1, rgba(255, 255, 255, 0.05));
}
.guest-banner-signup {
@@ -1,88 +1,82 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CloseIcon from '@mui/icons-material/Close'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import { useAuth } from '@app/auth/UseSession'
import { isUserAnonymous } from '@app/auth/supabase'
import { withBasePath } from '@app/constants/app'
import '@app/components/auth/GuestUserBanner.css'
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import PersonAddIcon from "@mui/icons-material/PersonAdd";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
import { withBasePath } from "@app/constants/app";
import "@app/components/auth/GuestUserBanner.css";
interface GuestUserBannerProps {
className?: string
className?: string;
}
// Ensure the toast only appears once per full page load, not on re-hydration
let hasShownThisLoad = false
let hasShownThisLoad = false;
/**
* Guest user toast encouraging account creation.
* Appears 2s after load, top-right of the viewport, offset by right rail.
*/
export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
const { t } = useTranslation()
const { session } = useAuth()
const [isDismissed, setIsDismissed] = useState(false)
const [visible, setVisible] = useState(false)
export function GuestUserBanner({ className = "" }: GuestUserBannerProps) {
const { t } = useTranslation();
const { session } = useAuth();
const [isDismissed, setIsDismissed] = useState(false);
const [visible, setVisible] = useState(false);
const isAnon = Boolean(session?.user && isUserAnonymous(session.user))
const isAnon = Boolean(session?.user && isUserAnonymous(session.user));
useEffect(() => {
if (!isAnon || hasShownThisLoad) return
if (!isAnon || hasShownThisLoad) return;
const timer = setTimeout(() => {
setVisible(true)
hasShownThisLoad = true
}, 2000)
setVisible(true);
hasShownThisLoad = true;
}, 2000);
return () => clearTimeout(timer)
}, [isAnon])
return () => clearTimeout(timer);
}, [isAnon]);
if (!isAnon || isDismissed || !visible) {
return null
return null;
}
const handleSignUp = () => {
window.location.href = withBasePath('/signup')
}
window.location.href = withBasePath("/signup");
};
const handleDismiss = () => {
setIsDismissed(true)
}
setIsDismissed(true);
};
return (
<div
className={`guest-banner ${className || ''}`}
role="status"
aria-live="polite"
>
<div className={`guest-banner ${className || ""}`} role="status" aria-live="polite">
<div className="guest-banner-content">
<div className="guest-banner-text">
<div className="guest-banner-title">
{t('guestBanner.title', "You're using Stirling PDF as a guest!")}
</div>
<div className="guest-banner-title">{t("guestBanner.title", "You're using Stirling PDF as a guest!")}</div>
<div className="guest-banner-message">
{t('guestBanner.message', 'Create a free account to save your work, access more features, and support the project.')}
{t(
"guestBanner.message",
"Create a free account to save your work, access more features, and support the project.",
)}
</div>
</div>
<div className="guest-banner-actions">
<button
onClick={handleDismiss}
aria-label={t('guestBanner.dismiss', 'Dismiss banner')}
aria-label={t("guestBanner.dismiss", "Dismiss banner")}
className="guest-banner-dismiss"
>
<CloseIcon className="guest-banner-icon" />
</button>
<button
onClick={handleSignUp}
className="guest-banner-signup"
>
<button onClick={handleSignUp} className="guest-banner-signup">
<PersonAddIcon className="guest-banner-signup-icon" />
{t('guestBanner.signUp', 'Sign Up Free')}
{t("guestBanner.signUp", "Sign Up Free")}
</button>
</div>
</div>
</div>
)
);
}
export default GuestUserBanner
export default GuestUserBanner;
@@ -1,23 +1,23 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth";
interface RequireAuthProps {
fallbackPath?: string
fallbackPath?: string;
}
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
const { session, loading } = useAuth()
const location = useLocation()
const { isAutoAuthenticating } = useAutoAnonymousAuth()
export function RequireAuth({ fallbackPath = "/login" }: RequireAuthProps) {
const { session, loading } = useAuth();
const location = useLocation();
const { isAutoAuthenticating } = useAutoAnonymousAuth();
// Safe development-only auth bypass
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
const isLocalhost = typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname);
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === "true");
if (devBypassEnabled) {
console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost')
return <Outlet />
console.warn("[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost");
return <Outlet />;
}
// Wait for both auth bootstrap and auto-anon to finish
@@ -29,16 +29,16 @@ export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
<p className="text-gray-600">Preparing your session</p>
</div>
</div>
)
);
}
if (!session) {
// Change the URL to /login
return <Navigate to={fallbackPath} replace state={{ from: location }} />
return <Navigate to={fallbackPath} replace state={{ from: location }} />;
}
// Render protected routes
return <Outlet />
return <Outlet />;
}
export default RequireAuth
export default RequireAuth;
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import Userback from '@userback/widget';
import { useAuth } from '@app/auth/UseSession';
import { useEffect, useRef } from "react";
import Userback from "@userback/widget";
import { useAuth } from "@app/auth/UseSession";
interface UserbackWidgetProps {
token: string;
@@ -30,14 +30,13 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
const options = {
user_data: {
id: user.id,
info: userInfo
}
info: userInfo,
},
};
// Initialize Userback
userbackRef.current = await Userback(token, options);
}
finally {
} finally {
initializingRef.current = false;
}
};
@@ -46,7 +45,7 @@ export default function UserbackWidget({ token }: UserbackWidgetProps) {
// Cleanup function
return () => {
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
if (userbackRef.current && typeof userbackRef.current.destroy === "function") {
userbackRef.current.destroy();
}
initializingRef.current = false;
@@ -1,4 +1,4 @@
import UserbackWidget from '@app/components/feedback/UserbackWidget';
import UserbackWidget from "@app/components/feedback/UserbackWidget";
export function HomePageExtensions() {
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
@@ -1,15 +1,15 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { renderButtons } from '@app/components/onboarding/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import React from "react";
import { Modal, Stack } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { renderButtons } from "@app/components/onboarding/renderButtons";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState";
import { BASE_PATH } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface SaasOnboardingModalProps {
opened: boolean;
@@ -24,17 +24,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
return null;
}
const {
currentStep,
totalSteps,
currentSlide,
slideDefinition,
flowState,
handleButtonAction,
} = flow;
const { currentStep, totalSteps, currentSlide, slideDefinition, flowState, handleButtonAction } = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
@@ -46,10 +39,10 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
</div>
);
};
@@ -66,17 +59,21 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<Stack
gap={0}
className={styles.modalContent}
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
@@ -89,20 +86,17 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
</div>
</div>
<div
<div
className={styles.modalBody}
style={{
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
<div key={`title-${currentSlide.key}`} className={`${styles.title} ${styles.titleText}`}>
{currentSlide.title}
</div>
@@ -1,8 +1,8 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { TFunction } from 'i18next';
import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { TFunction } from "i18next";
import { ButtonDefinition, type FlowState, type ButtonAction } from "@app/components/onboarding/saasOnboardingFlowConfig";
interface RenderButtonsProps {
slideDefinition: {
@@ -15,39 +15,39 @@ interface RenderButtonsProps {
}
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
const label = button.label ?? "";
if (!label) return "";
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
if (button.type === "icon") {
return (
<ActionIcon
key={button.key}
@@ -57,18 +57,18 @@ export function renderButtons({ slideDefinition, flowState, onAction, t }: Rende
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const variant = button.variant ?? "secondary";
const label = resolveButtonLabel(button);
return (
@@ -1,8 +1,8 @@
import { TrialStatus } from '@app/auth/UseSession';
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
import { TrialStatus } from "@app/auth/UseSession";
import { FLOW_SEQUENCES, SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
export interface FlowConfig {
type: 'saas-trial' | 'saas-paid';
type: "saas-trial" | "saas-paid";
ids: SlideId[];
}
@@ -13,28 +13,23 @@ export interface FlowConfig {
* @param _isPro - Whether user has Pro subscription
* @returns FlowConfig with the appropriate slide sequence
*/
export function resolveSaasFlow(
trialStatus: TrialStatus | null,
_isPro: boolean | null
): FlowConfig {
export function resolveSaasFlow(trialStatus: TrialStatus | null, _isPro: boolean | null): FlowConfig {
// Show free trial card if:
// 1. User has active trial (isTrialing = true)
// 2. Trial has not expired (daysRemaining > 0)
// 3. User is not paid Pro (or Pro is from trial)
const hasActiveTrial =
trialStatus?.isTrialing === true &&
trialStatus.daysRemaining > 0;
const hasActiveTrial = trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0;
if (hasActiveTrial) {
return {
type: 'saas-trial',
type: "saas-trial",
ids: FLOW_SEQUENCES.saasTrialUser,
};
}
// For paid users, expired trials, or no trial info
return {
type: 'saas-paid',
type: "saas-paid",
ids: FLOW_SEQUENCES.saasPaidUser,
};
}
@@ -1,18 +1,14 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide';
import { SlideConfig } from '@app/types/types';
import { TrialStatus } from '@app/auth/UseSession';
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide";
import { SlideConfig } from "@app/types/types";
import { TrialStatus } from "@app/auth/UseSession";
export type SlideId = 'welcome' | 'free-trial' | 'desktop-install';
export type SlideId = "welcome" | "free-trial" | "desktop-install";
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
export type HeroType = "rocket" | "dual-icon" | "diamond";
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'download-selected';
export type ButtonAction = "next" | "prev" | "close" | "download-selected";
export type FlowState = Record<string, never>;
@@ -36,11 +32,11 @@ export interface HeroDefinition {
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
type: "button" | "icon";
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
@@ -53,82 +49,82 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
id: 'welcome',
welcome: {
id: "welcome",
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
key: "welcome-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'free-trial': {
id: 'free-trial',
"free-trial": {
id: "free-trial",
createSlide: ({ trialStatus }) => {
if (!trialStatus) {
throw new Error('Trial status is required for free-trial slide');
throw new Error("Trial status is required for free-trial slide");
}
return FreeTrialSlide({ trialStatus });
},
hero: { type: 'diamond' },
hero: { type: "diamond" },
buttons: [
{
key: 'trial-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "trial-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'trial-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
key: "trial-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'desktop-install': {
id: 'desktop-install',
"desktop-install": {
id: "desktop-install",
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
hero: { type: "dual-icon" },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "desktop-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
key: "desktop-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "close",
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
key: "desktop-download",
type: "button",
label: "onboarding.buttons.download",
variant: "primary",
group: "right",
action: "download-selected",
},
],
},
};
export const FLOW_SEQUENCES = {
saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
};
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { TrialStatus } from '@app/auth/UseSession';
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { TrialStatus } from "@app/auth/UseSession";
interface FreeTrialSlideProps {
trialStatus: TrialStatus;
@@ -11,11 +11,7 @@ interface FreeTrialSlideProps {
function FreeTrialSlideTitle() {
const { t } = useTranslation();
return (
<span>
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
</span>
);
return <span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>;
}
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
@@ -23,58 +19,60 @@ const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
// Format the trial end date
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
year: "numeric",
month: "long",
day: "numeric",
});
// Determine which message to show based on payment method
const afterTrialMessage = trialStatus.hasScheduledSub
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
? t("onboarding.freeTrial.afterTrialWithPayment", "Your Pro subscription will start automatically when the trial ends.")
: trialStatus.hasPaymentMethod
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
: t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.');
? t("onboarding.freeTrial.afterTrialWithPayment", "Your Pro subscription will start automatically when the trial ends.")
: t(
"onboarding.freeTrial.afterTrialWithoutPayment",
"After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access.",
);
// Pluralize days remaining
const daysText = trialStatus.daysRemaining === 1
? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining })
: t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining });
const daysText =
trialStatus.daysRemaining === 1
? t("onboarding.freeTrial.daysRemainingSingular", "{{days}} day remaining", { days: trialStatus.daysRemaining })
: t("onboarding.freeTrial.daysRemaining", "{{days}} days remaining", { days: trialStatus.daysRemaining });
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<p>
{t(
'onboarding.freeTrial.body',
'You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.'
"onboarding.freeTrial.body",
"You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.",
)}
</p>
<div style={{
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
padding: '1rem',
textAlign: 'center'
}}>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{daysText}
</div>
<div style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('onboarding.freeTrial.trialEnds', 'Trial ends {{date}}', { date: trialEndDate })}
<div
style={{
background: "rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "1rem",
textAlign: "center",
}}
>
<div style={{ fontSize: "1.25rem", fontWeight: "bold", marginBottom: "0.5rem" }}>{daysText}</div>
<div style={{ fontSize: "0.9rem", opacity: 0.9 }}>
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", { date: trialEndDate })}
</div>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{afterTrialMessage}
</p>
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
</div>
);
};
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
return {
key: 'free-trial',
key: "free-trial",
title: <FreeTrialSlideTitle />,
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
background: {
gradientStops: ['#10B981', '#06B6D4'],
gradientStops: ["#10B981", "#06B6D4"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
@@ -1,20 +1,20 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { useOs } from '@app/hooks/useOs';
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useAuth } from "@app/auth/UseSession";
import { useOs } from "@app/hooks/useOs";
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/saasOnboardingFlowConfig';
import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
} from "@app/components/onboarding/saasOnboardingFlowConfig";
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
interface UseSaasOnboardingStateResult {
currentStep: number;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]["createSlide"]>;
flowState: FlowState;
handleButtonAction: (action: ButtonAction) => void;
}
@@ -24,13 +24,10 @@ interface UseSaasOnboardingStateProps {
onClose: () => void;
}
export function useSaasOnboardingState({
opened,
onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
export function useSaasOnboardingState({ opened, onClose }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth();
const osType = useOs();
const selectedDownloadUrlRef = useRef<string>('');
const selectedDownloadUrlRef = useRef<string>("");
const [currentStep, setCurrentStep] = useState<number>(0);
@@ -44,28 +41,28 @@ export function useSaasOnboardingState({
// Determine OS details for desktop download
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "linux-x64":
case "linux-arm64":
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
return { label: "", url: "" };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
];
return options.filter(opt => opt.url);
return options.filter((opt) => opt.url);
}, []);
// Store selected download URL
@@ -74,10 +71,7 @@ export function useSaasOnboardingState({
}, []);
// Resolve flow based on trial status
const resolvedFlow = useMemo(
() => resolveSaasFlow(trialStatus, isPro),
[trialStatus, isPro]
);
const resolvedFlow = useMemo(() => resolveSaasFlow(trialStatus, isPro), [trialStatus, isPro]);
const flowSlideIds = resolvedFlow.ids;
const totalSteps = flowSlideIds.length;
@@ -118,7 +112,7 @@ export function useSaasOnboardingState({
const handleButtonAction = useCallback(
(action: ButtonAction) => {
switch (action) {
case 'next':
case "next":
// If on last slide, close modal
if (currentStep === maxIndex) {
onClose();
@@ -126,17 +120,17 @@ export function useSaasOnboardingState({
goNext();
}
return;
case 'prev':
case "prev":
goPrev();
return;
case 'close':
case "close":
onClose();
return;
case 'download-selected': {
case "download-selected": {
// Open download URL in new tab
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
@@ -151,7 +145,7 @@ export function useSaasOnboardingState({
return;
}
},
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
[currentStep, maxIndex, goNext, goPrev, onClose, os.url],
);
const flowState: FlowState = {};
@@ -1,16 +1,16 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Modal, Button, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous } from '@app/auth/supabase';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections';
import { NavKey } from '@app/components/shared/config/types';
import { withBasePath } from '@app/constants/app';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
import React, { useCallback, useMemo, useState, useEffect } from "react";
import { Modal, Button, Text, ActionIcon } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import Overview from "@app/components/shared/config/configSections/Overview";
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
import { NavKey } from "@app/components/shared/config/types";
import { withBasePath } from "@app/constants/app";
import "@app/components/shared/AppConfigModal.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
interface AppConfigModalProps {
opened: boolean;
@@ -23,7 +23,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const { signOut, user, creditBalance, refreshCredits } = useAuth();
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>('overview');
const [active, setActive] = useState<NavKey>("overview");
const [notice, setNotice] = useState<string | null>(null);
// Check if user can access billing features (non-anonymous users only)
@@ -35,52 +35,55 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
window.addEventListener("appConfig:navigate", handler as EventListener);
return () => window.removeEventListener("appConfig:navigate", handler as EventListener);
}, []);
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined;
if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) {
if (detail?.notice && (detail?.key ? detail.key === "plan" : true)) {
setNotice(detail.notice);
}
};
window.addEventListener('appConfig:notice', handler as EventListener);
return () => window.removeEventListener('appConfig:notice', handler as EventListener);
window.addEventListener("appConfig:notice", handler as EventListener);
return () => window.removeEventListener("appConfig:notice", handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance);
if (active !== "plan") return;
console.log("[AppConfigModal] Opening Plan section. Current creditBalance:", creditBalance);
(async () => {
try {
await refreshCredits();
} catch (e) {
console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e);
console.warn("[AppConfigModal] Failed to refresh credits on Plan open:", e);
}
})();
}, [opened, active]);
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance);
if (active !== "plan") return;
console.log("[AppConfigModal] Credit balance updated while viewing Plan:", creditBalance);
}, [opened, active, creditBalance]);
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
const isDev = process.env.NODE_ENV === 'development';
const colors = useMemo(
() => ({
navBg: "var(--modal-nav-bg)",
sectionTitle: "var(--modal-nav-section-title)",
navItem: "var(--modal-nav-item)",
navItemActive: "var(--modal-nav-item-active)",
navItemActiveBg: "var(--modal-nav-item-active-bg)",
contentBg: "var(--modal-content-bg)",
headerBorder: "var(--modal-header-border)",
}),
[],
);
const isDev = process.env.NODE_ENV === "development";
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
@@ -97,15 +100,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.label;
}
return '';
return "";
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.component;
}
return null;
@@ -114,95 +117,96 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? "mobile" : ""}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map((section) => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text
size="xs"
fw={600}
c={colors.sectionTitle}
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map((item) => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
style={{
background: isActive ? colors.navItemActiveBg : "transparent",
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">
{activeLabel}
{active === 'plan' && notice ? (
<span style={{ marginLeft: 8, fontWeight: 600, color: 'var(--mantine-color-yellow-7)' }}>
{notice}
</span>
) : null}
</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">
{activeLabel}
{active === "plan" && notice ? (
<span style={{ marginLeft: 8, fontWeight: 600, color: "var(--mantine-color-yellow-7)" }}> {notice}</span>
) : null}
</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">{activeComponent}</div>
</div>
</div>
</div>
</div>
</Modal>
{/* Confirm logout modal */}
{/* Confirm logout modal */}
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
@@ -213,7 +217,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div className="confirm-modal-content">
<Text>Are you sure you want to sign out?</Text>
<div className="confirm-modal-buttons">
<Button variant="default" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
color="red"
onClick={async () => {
@@ -222,7 +228,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
} finally {
setConfirmOpen(false);
onClose();
window.location.href = withBasePath('/login');
window.location.href = withBasePath("/login");
}
}}
>
@@ -1,8 +1,8 @@
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = 'info' | 'warning';
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
@@ -15,18 +15,18 @@ const toneStyles: Record<
}
> = {
info: {
background: 'var(--mantine-color-blue-0)',
border: 'var(--mantine-color-blue-2)',
text: 'var(--mantine-color-blue-9)',
icon: 'var(--mantine-color-blue-6)',
buttonColor: 'blue',
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: 'var(--mantine-color-orange-0)',
border: 'var(--mantine-color-orange-3)',
text: 'var(--mantine-color-orange-9)',
icon: 'var(--mantine-color-orange-7)',
buttonColor: 'orange',
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--mantine-color-orange-9)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
@@ -47,7 +47,7 @@ interface InfoBannerProps {
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
buttonTextColor?: string; // SaaS-specific for dark theme buttons
minHeight?: number | string;
closeIconColor?: string; // SaaS-specific for dark theme
@@ -62,19 +62,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
buttonIcon = "check-circle-rounded",
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
@@ -96,14 +96,14 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
}}
>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{icon && (
typeof icon === 'string' ? (
{icon &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width="1.2rem"
@@ -111,23 +111,15 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{icon}
</div>
)
)}
<div style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>{icon}</div>
))}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
>
<Text fw={title ? 400 : 500} size="sm" style={{ color: textColor ?? toneStyle.text }} lineClamp={2}>
{message}
</Text>
</Stack>
@@ -148,13 +140,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
color: buttonTextColor,
},
}
: buttonVariant !== 'white' && buttonVariant !== 'filled'
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
: buttonVariant !== "white" && buttonVariant !== "filled"
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
}
>
{buttonText}
@@ -163,7 +155,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { Button } from '@mantine/core';
import { usePlans } from '@app/hooks/usePlans';
import { useState } from "react";
import { supabase } from "@app/auth/supabase";
import { Button } from "@mantine/core";
import { usePlans } from "@app/hooks/usePlans";
interface TrialStatus {
isTrialing: boolean;
@@ -12,8 +12,8 @@ interface TrialStatus {
}
export function ManageBillingButton({
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
children = 'Manage billing',
returnUrl = typeof window !== "undefined" ? window.location.href : "/",
children = "Manage billing",
trialStatus,
}: {
returnUrl?: string;
@@ -25,7 +25,7 @@ export function ManageBillingButton({
const { data } = usePlans();
// Hide for free plan users
if (!data || data.currentPlan.id === 'free') {
if (!data || data.currentPlan.id === "free") {
return null;
}
@@ -38,16 +38,17 @@ export function ManageBillingButton({
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>('manage-billing', {
const { data, error } = await supabase.functions.invoke<{ url: string; error?: string }>("manage-billing", {
body: {
name: 'Functions',
return_url: returnUrl},
})
name: "Functions",
return_url: returnUrl,
},
});
if (error) throw error;
if (!data || 'error' in data) throw new Error(data?.error ?? 'No portal URL');
if (!data || "error" in data) throw new Error(data?.error ?? "No portal URL");
window.location.href = data.url;
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Could not open billing portal');
setErr(e instanceof Error ? e.message : "Could not open billing portal");
} finally {
setLoading(false);
}
@@ -56,7 +57,7 @@ export function ManageBillingButton({
return (
<div>
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
{loading ? 'Opening…' : children}
{loading ? "Opening…" : children}
</Button>
{err && <div className="mt-2 text-red-600">{err}</div>}
</div>
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
@@ -9,16 +9,11 @@ interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
* Adds both the PostHog no-capture class and the Userback opt-out class
* while keeping the same API and layout behavior (display: contents).
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const baseClass = 'ph-no-capture userback-block';
export const PrivateContent: React.FC<PrivateContentProps> = ({ children, className = "", style, ...props }) => {
const baseClass = "ph-no-capture userback-block";
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
const combinedStyle = {
display: 'contents' as const,
display: "contents" as const,
...style,
};
@@ -1,16 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
import React, { useState, useEffect } from "react";
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { supabase } from "@app/auth/supabase";
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
export type PurchaseType = 'subscription' | 'credits';
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
export type PlanID = 'pro' | null;
export type PurchaseType = "subscription" | "credits";
export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null;
export type PlanID = "pro" | null;
interface StripeCheckoutProps {
opened: boolean;
@@ -26,7 +26,7 @@ interface StripeCheckoutProps {
// Proprietary-specific props (for compatibility)
planGroup?: unknown;
minimumSeats?: number;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
@@ -37,7 +37,7 @@ interface StripeCheckoutProps {
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
status: "idle" | "loading" | "ready" | "success" | "error";
clientSecret?: string;
error?: string;
sessionParams?: {
@@ -56,71 +56,71 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
planName,
isTrialConversion,
onSuccess,
onError
onError,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const [state, setState] = useState<CheckoutState>({ status: "idle" });
const createCheckoutSession = async () => {
try {
setState({ status: 'loading' });
setState({ status: "loading" });
const { data, error } = await supabase.functions.invoke('create-checkout', {
const { data, error } = await supabase.functions.invoke("create-checkout", {
body: {
purchase_type: purchaseType,
ui_mode: 'embedded',
ui_mode: "embedded",
plan: planId,
credits_pack: creditsPack,
callback_base_url: window.location.origin,
trial_conversion: isTrialConversion || false
}
trial_conversion: isTrialConversion || false,
},
});
if (error) {
throw new Error(error.message || 'Failed to create checkout session');
throw new Error(error.message || "Failed to create checkout session");
}
if (!data) {
throw new Error('No data received from server');
throw new Error("No data received from server");
}
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
const jsonData = typeof data === "string" ? JSON.parse(data) : data;
if (!jsonData?.clientSecret) {
throw new Error('No client secret received from server');
throw new Error("No client secret received from server");
}
setState({
status: 'ready',
status: "ready",
clientSecret: jsonData.clientSecret,
sessionParams: {
purchaseType: purchaseType!,
planId: planId!,
creditsPack: creditsPack!
}
creditsPack: creditsPack!,
},
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
setState({
status: 'error',
error: errorMessage
status: "error",
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handlePaymentComplete = () => {
setState({ status: 'success' });
setState({ status: "success" });
// Call success callback immediately - parent will handle timing
onSuccess?.('');
onSuccess?.("");
// Note: Parent (Plan.tsx) now handles the delay and modal closing
};
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
onClose();
};
@@ -129,35 +129,35 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
if (opened) {
// Check if we need a new session (first time or parameters changed)
const needsNewSession =
state.status === 'idle' ||
state.status === "idle" ||
!state.sessionParams ||
state.sessionParams.purchaseType !== purchaseType ||
state.sessionParams.planId !== planId ||
state.sessionParams.creditsPack !== creditsPack;
if (needsNewSession) {
console.log('Creating new checkout session:', { purchaseType, planId, creditsPack });
console.log("Creating new checkout session:", { purchaseType, planId, creditsPack });
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
setState({ status: "idle", clientSecret: undefined, error: undefined, sessionParams: undefined });
}
}, [opened, purchaseType, planId, creditsPack]);
const renderContent = () => {
switch (state.status) {
case 'loading':
case "loading":
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</div>
);
case 'ready':
case "ready":
if (!state.clientSecret) return null;
return (
@@ -166,34 +166,37 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
case 'success':
case "success":
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
<Stack gap="md">
<Text size="sm">
{t('payment.successMessage', 'Your plan has been upgraded successfully. You will receive a confirmation email shortly.')}
{t(
"payment.successMessage",
"Your plan has been upgraded successfully. You will receive a confirmation email shortly.",
)}
</Text>
<Text size="xs" c="dimmed">
{t('payment.autoClose', 'This window will close automatically...')}
{t("payment.autoClose", "This window will close automatically...")}
</Text>
</Stack>
</Alert>
);
case 'error':
case "error":
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -211,7 +214,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
{t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName })}
</Text>
</div>
}
@@ -1,9 +1,9 @@
import { Modal, Stack, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { Modal, Stack, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface TrialExpiredModalProps {
opened: boolean;
@@ -15,15 +15,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
const { t } = useTranslation();
// Use CSS variables for theme colors
const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b';
const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444';
const amberColor = getComputedStyle(document.documentElement).getPropertyValue("--color-amber-500").trim() || "#f59e0b";
const redColor = getComputedStyle(document.documentElement).getPropertyValue("--color-red-500").trim() || "#ef4444";
const gradientStops: [string, string] = [amberColor, redColor];
const circles = [
{
position: 'bottom-left' as const,
position: "bottom-left" as const,
size: 270, // 16.875rem
color: 'rgba(255, 255, 255, 0.25)',
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24, // 1.5rem
duration: 4.5,
@@ -31,9 +31,9 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
offsetY: 14, // 0.875rem
},
{
position: 'top-right' as const,
position: "top-right" as const,
size: 300, // 18.75rem
color: 'rgba(255, 255, 255, 0.2)',
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28, // 1.75rem
duration: 4.5,
@@ -57,26 +57,25 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<Stack
gap={0}
className={styles.modalContent}
style={{ height: "100%", maxHeight: "90vh", display: "flex", flexDirection: "column" }}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={gradientStops}
circles={circles}
isActive
slideKey="trial-expired"
/>
<AnimatedSlideBackground gradientStops={gradientStops} circles={circles} isActive slideKey="trial-expired" />
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
</div>
</div>
</div>
@@ -85,28 +84,26 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{t('plan.trial.expired', 'Your Trial Has Ended')}
</div>
<div className={`${styles.title} ${styles.titleText}`}>{t("plan.trial.expired", "Your Trial Has Ended")}</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
'plan.trial.expiredMessage',
'Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.'
"plan.trial.expiredMessage",
"Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier.",
)}
</div>
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t('plan.trial.freeTierLimitations', 'Free tier includes basic PDF tools with usage limits.')}
{t("plan.trial.freeTierLimitations", "Free tier includes basic PDF tools with usage limits.")}
</div>
</div>
@@ -127,10 +124,10 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
<div
className="trial-button-container"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
justifyContent: 'space-between',
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
justifyContent: "space-between",
}}
>
<Button
@@ -139,15 +136,15 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
size="sm"
className="trial-modal-button"
style={{
fontSize: '0.8125rem',
padding: '0.5rem 1rem',
height: 'auto',
minWidth: '8.125rem',
flex: '0 1 auto',
border: '0'
fontSize: "0.8125rem",
padding: "0.5rem 1rem",
height: "auto",
minWidth: "8.125rem",
flex: "0 1 auto",
border: "0",
}}
>
{t('plan.trial.continueWithFree', 'Continue with Free')}
{t("plan.trial.continueWithFree", "Continue with Free")}
</Button>
<Button
@@ -155,18 +152,18 @@ export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpired
size="md"
className="trial-modal-button trial-modal-button-primary"
style={{
background: 'linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))',
color: '#FFFFFF',
fontSize: '0.9375rem',
background: "linear-gradient(135deg, var(--color-amber-500), var(--color-red-500))",
color: "#FFFFFF",
fontSize: "0.9375rem",
fontWeight: 600,
padding: '0.75rem 1.5rem',
height: 'auto',
border: 'none',
minWidth: '10.625rem',
flex: '0 1 auto',
padding: "0.75rem 1.5rem",
height: "auto",
border: "none",
minWidth: "10.625rem",
flex: "0 1 auto",
}}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
</div>
</div>
@@ -1,19 +1,19 @@
import { useEffect, useState, useCallback } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
import { BASE_PATH } from '@app/constants/app';
import { useEffect, useState, useCallback } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
import { BASE_PATH } from "@app/constants/app";
const SESSION_STORAGE_KEY = 'trialBannerDismissed';
const SESSION_STORAGE_KEY = "trialBannerDismissed";
export function TrialStatusBanner() {
const { setBanner } = useBanner();
const { t } = useTranslation();
const { trialStatus } = useAuth();
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true';
return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true";
});
const [checkoutOpen, setCheckoutOpen] = useState(false);
@@ -28,7 +28,7 @@ export function TrialStatusBanner() {
!dismissed;
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
console.log('Subscription scheduled - hiding trial banner');
console.log("Subscription scheduled - hiding trial banner");
}
const handleOpenCheckout = useCallback(() => {
@@ -37,7 +37,7 @@ export function TrialStatusBanner() {
const handleDismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
sessionStorage.setItem(SESSION_STORAGE_KEY, "true");
}, []);
useEffect(() => {
@@ -46,15 +46,15 @@ export function TrialStatusBanner() {
return;
}
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
month: 'short',
day: 'numeric'
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
const message = t(
'plan.trial.message',
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`,
{ days: trialStatus.daysRemaining, date: trialEndDate }
"plan.trial.message",
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? "s" : ""} (${trialEndDate}). Subscribe to continue Pro access.`,
{ days: trialStatus.daysRemaining, date: trialEndDate },
);
const logoIcon = (
@@ -62,9 +62,9 @@ export function TrialStatusBanner() {
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling PDF"
style={{
width: '1.5rem',
height: '1.5rem',
objectFit: 'contain'
width: "1.5rem",
height: "1.5rem",
objectFit: "contain",
}}
/>
);
@@ -74,7 +74,7 @@ export function TrialStatusBanner() {
icon={logoIcon}
tone="info"
message={message}
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
buttonText={t("plan.trial.subscribe", "Subscribe to Pro")}
buttonIcon="credit-card-rounded"
onButtonClick={handleOpenCheckout}
onDismiss={handleDismiss}
@@ -88,7 +88,7 @@ export function TrialStatusBanner() {
buttonVariant="white"
buttonTextColor="var(--mantine-color-dark-9)"
closeIconColor="rgba(255, 255, 255, 0.7)"
/>
/>,
);
return () => {
@@ -112,7 +112,7 @@ export function TrialStatusBanner() {
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('Checkout error:', error)}
onError={(error) => console.error("Checkout error:", error)}
isTrialConversion={true}
/>
)}
@@ -1,19 +1,19 @@
import React, { useEffect, useMemo, useRef, useCallback, useId } from 'react';
import { Group, Loader, Text } from '@mantine/core';
import * as d3 from 'd3';
import { StackedBarChartProps, TooltipData, FractionData } from '@app/types/charts';
import { generateTooltipHTML } from '@app/components/shared/charts/stackedBarChart/StackedBarTooltip';
import { detectTheme, getChartThemeVars } from '@app/components/shared/charts/utils/themeUtils';
import { createTooltipPositioner } from '@app/components/shared/charts/utils/tooltipUtils';
import { createRoundedRectPath, createScale } from '@app/components/shared/charts/utils/d3Utils';
import React, { useEffect, useMemo, useRef, useCallback, useId } from "react";
import { Group, Loader, Text } from "@mantine/core";
import * as d3 from "d3";
import { StackedBarChartProps, TooltipData, FractionData } from "@app/types/charts";
import { generateTooltipHTML } from "@app/components/shared/charts/stackedBarChart/StackedBarTooltip";
import { detectTheme, getChartThemeVars } from "@app/components/shared/charts/utils/themeUtils";
import { createTooltipPositioner } from "@app/components/shared/charts/utils/tooltipUtils";
import { createRoundedRectPath, createScale } from "@app/components/shared/charts/utils/d3Utils";
export default function StackedBarChart({
fractions,
width = 640,
height = 22,
export default function StackedBarChart({
fractions,
width = 640,
height = 22,
showLegend = true,
className = '',
tooltipPosition = 'top',
className = "",
tooltipPosition = "top",
loading = false,
animate = true,
animationDurationMs = 900,
@@ -32,96 +32,102 @@ export default function StackedBarChart({
// Memoize tooltip positioner
const tooltipPositioner = useMemo(() => createTooltipPositioner(tooltipPosition), [tooltipPosition]);
const positionTooltip = useCallback((event: MouseEvent) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
}, [tooltipPositioner]);
const positionTooltip = useCallback(
(event: MouseEvent) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltipPositioner.positionTooltip(event, tooltip, containerRef.current!);
},
[tooltipPositioner],
);
const setTooltipContent = useCallback((labelHtml: string) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.innerHTML = labelHtml;
tooltip.style.background = themeVars.background;
tooltip.style.color = themeVars.textPrimary;
tooltip.style.border = themeVars.border;
tooltip.style.boxShadow = themeVars.boxShadow;
tooltip.style.padding = '8px 10px';
tooltip.style.fontSize = '12px';
tooltip.style.lineHeight = '1.25';
tooltip.style.borderRadius = '8px';
}, [themeVars]);
const setTooltipContent = useCallback(
(labelHtml: string) => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.innerHTML = labelHtml;
tooltip.style.background = themeVars.background;
tooltip.style.color = themeVars.textPrimary;
tooltip.style.border = themeVars.border;
tooltip.style.boxShadow = themeVars.boxShadow;
tooltip.style.padding = "8px 10px";
tooltip.style.fontSize = "12px";
tooltip.style.lineHeight = "1.25";
tooltip.style.borderRadius = "8px";
},
[themeVars],
);
const hideTooltip = useCallback(() => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
tooltip.style.opacity = '0';
tooltip.style.opacity = "0";
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
container.innerHTML = "";
// Calculate total capacity (sum of all denominators)
const totalCapacity = fractions.reduce((sum: number, fraction: FractionData) => sum + fraction.denominator, 0);
if (totalCapacity === 0 && !loading) return;
// Create data for the bar segments
const data = fractions.map((fraction: FractionData) => ({
...fraction,
value: fraction.numerator,
remaining: fraction.denominator - fraction.numerator
remaining: fraction.denominator - fraction.numerator,
}));
const radius = 8;
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('role', ariaLabel ? 'img' : null)
.attr('aria-label', ariaLabel || null);
.append("svg")
.attr("width", "100%")
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("role", ariaLabel ? "img" : null)
.attr("aria-label", ariaLabel || null);
const x = createScale([0, totalCapacity], [0, width]);
let cursor = 0;
const g = svg.append('g');
const g = svg.append("g");
// Skip drawing the bar visuals entirely when loading to avoid gray bar under spinner
if (!loading) {
// Create a single rounded rectangle for the entire bar background
const totalBarWidth = x(totalCapacity);
g.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', themeVars.inactive)
.attr('stroke', themeVars.cardBorder);
g.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", totalBarWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", themeVars.inactive)
.attr("stroke", themeVars.cardBorder);
// Define a clipPath that will reveal the used portion from left to right
const defs = svg.append('defs');
const defs = svg.append("defs");
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius);
.append("clipPath")
.attr("id", clipId)
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 0)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius);
// Group to hold the used segments and apply clip-path
const usedGroup = svg.append('g').attr('clip-path', `url(#${clipId})`);
const usedGroup = svg.append("g").attr("clip-path", `url(#${clipId})`);
// Render used segments on top of the background
data.forEach((fraction: typeof data[number], index: number) => {
data.forEach((fraction: (typeof data)[number], index: number) => {
if (fraction.value <= 0) return;
const segWidth = x(fraction.value);
@@ -133,131 +139,145 @@ export default function StackedBarChart({
if (isFirst && isLast) {
// Single segment: fully rounded
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', fraction.color);
usedGroup
.append("rect")
.attr("x", xPos)
.attr("y", 0)
.attr("width", segWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", fraction.color);
} else if (isFirst) {
// First segment: rounded on left side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: true,
topRight: false,
bottomLeft: true,
bottomRight: false
bottomRight: false,
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
} else if (isLast) {
// Last segment: rounded on right side only
const path = createRoundedRectPath(xPos, 0, segWidth, height, radius, {
topLeft: false,
topRight: true,
bottomLeft: false,
bottomRight: true
bottomRight: true,
});
usedGroup.append('path')
.attr('d', path)
.attr('fill', fraction.color);
usedGroup.append("path").attr("d", path).attr("fill", fraction.color);
} else {
// Middle segments: no rounded edges
usedGroup.append('rect')
.attr('x', xPos)
.attr('y', 0)
.attr('width', segWidth)
.attr('height', height)
.attr('fill', fraction.color);
usedGroup
.append("rect")
.attr("x", xPos)
.attr("y", 0)
.attr("width", segWidth)
.attr("height", height)
.attr("fill", fraction.color);
}
});
// Add a transparent overlay for hover events on the entire bar (outside clip path)
svg.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', totalBarWidth)
.attr('height', height)
.attr('rx', radius)
.attr('ry', radius)
.attr('fill', 'transparent')
.style('pointer-events', 'all')
.on('mouseenter', (event: MouseEvent) => {
svg
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", totalBarWidth)
.attr("height", height)
.attr("rx", radius)
.attr("ry", radius)
.attr("fill", "transparent")
.style("pointer-events", "all")
.on("mouseenter", (event: MouseEvent) => {
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
const html = generateTooltipHTML(tooltipData);
setTooltipContent(html);
const tooltip = tooltipRef.current;
if (tooltip) tooltip.style.opacity = '1';
if (tooltip) tooltip.style.opacity = "1";
positionTooltip(event as unknown as MouseEvent);
})
.on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on('mouseleave', hideTooltip);
.on("mousemove", (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on("mouseleave", hideTooltip);
// Animate reveal of used segments (only on first load, not on re-renders)
const totalUsed = data.reduce((sum: number, f: typeof data[number]) => sum + f.value, 0);
const totalUsed = data.reduce((sum: number, f: (typeof data)[number]) => sum + f.value, 0);
const revealTo = x(totalUsed);
if (animate && !hasAnimatedRef.current) {
clipRect.transition().duration(animationDurationMs).attr('width', revealTo);
clipRect.transition().duration(animationDurationMs).attr("width", revealTo);
hasAnimatedRef.current = true;
} else {
clipRect.attr('width', revealTo);
clipRect.attr("width", revealTo);
}
}
return () => { container.innerHTML = ''; };
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
return () => {
container.innerHTML = "";
};
}, [
fractions,
width,
height,
tooltipPosition,
loading,
animate,
animationDurationMs,
clipId,
themeVars,
setTooltipContent,
hideTooltip,
positionTooltip,
]);
return (
<div className={className}>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<div ref={containerRef} />
<div
ref={tooltipRef}
style={{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 120ms ease',
zIndex: 1000
style={{
position: "absolute",
left: 0,
top: 0,
pointerEvents: "none",
opacity: 0,
transition: "opacity 120ms ease",
zIndex: 1000,
}}
/>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Loader size="sm" color="blue" />
</div>
)}
</div>
{showLegend && (
<Group gap="lg" mt="sm">
{fractions.map((fraction: FractionData, index: number) => (
<Group key={index} gap={6}>
<span
style={{
width: 10,
height: 10,
background: fraction.color,
display: 'inline-block',
borderRadius: 2
}}
<span
style={{
width: 10,
height: 10,
background: fraction.color,
display: "inline-block",
borderRadius: 2,
}}
/>
<Text size="sm">{fraction.name}</Text>
</Group>
))}
<Group gap={6}>
<span
style={{
width: 10,
height: 10,
background: themeVars.inactive,
display: 'inline-block',
borderRadius: 2,
outline: `1px solid ${themeVars.cardBorder}`
}}
<span
style={{
width: 10,
height: 10,
background: themeVars.inactive,
display: "inline-block",
borderRadius: 2,
outline: `1px solid ${themeVars.cardBorder}`,
}}
/>
<Text size="sm">Remaining</Text>
</Group>
@@ -1,5 +1,5 @@
import React from 'react';
import { TooltipData } from '@app/types/charts';
import React from "react";
import { TooltipData } from "@app/types/charts";
interface StackedBarTooltipProps {
data: TooltipData;
@@ -9,16 +9,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
const { fractions } = data;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', whiteSpace: 'nowrap' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", whiteSpace: "nowrap" }}>
{fractions.map((f, index) => (
<div key={index} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span style={{
display: 'inline-block',
width: '10px',
height: '10px',
background: f.color,
borderRadius: '2px'
}}></span>
<div key={index} style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<span
style={{
display: "inline-block",
width: "10px",
height: "10px",
background: f.color,
borderRadius: "2px",
}}
></span>
<span>
<strong>{f.name}</strong> {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
</span>
@@ -30,14 +32,18 @@ export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
export function generateTooltipHTML(data: TooltipData): string {
const { fractions } = data;
return `
<div style="display:flex;flex-direction:column;gap:6px;white-space:nowrap;">
${fractions.map(f => `
${fractions
.map(
(f) => `
<div style="display:flex;gap:8px;align-items:center;">
<span style="display:inline-block;width:10px;height:10px;background:${f.color};border-radius:2px"></span>
<span><strong>${f.name}</strong> — ${f.numeratorLabel}: ${f.numerator} · ${f.denominatorLabel}: ${Math.max(0, f.denominator - f.numerator)}</span>
</div>
`).join('')}
`,
)
.join("")}
</div>`;
}
@@ -2,7 +2,7 @@
* Reusable D3 utility functions for chart creation
*/
import * as d3 from 'd3';
import * as d3 from "d3";
export interface ChartDimensions {
width: number;
@@ -28,17 +28,17 @@ export interface AnimationConfig {
* @returns The created SVG selection
*/
export function createSVG(
container: HTMLElement,
container: HTMLElement,
dimensions: ChartDimensions,
className?: string
className?: string,
): d3.Selection<SVGSVGElement, unknown, null, undefined> {
const svg = d3
.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', dimensions.height)
.attr('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`)
.attr('class', className || '');
.append("svg")
.attr("width", "100%")
.attr("height", dimensions.height)
.attr("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`)
.attr("class", className || "");
return svg;
}
@@ -53,17 +53,17 @@ export function createSVG(
export function createClipPath(
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
clipId: string,
dimensions: ChartDimensions
dimensions: ChartDimensions,
): d3.Selection<SVGRectElement, unknown, null, undefined> {
const defs = svg.append('defs');
const defs = svg.append("defs");
const clipRect = defs
.append('clipPath')
.attr('id', clipId)
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 0)
.attr('height', dimensions.height);
.append("clipPath")
.attr("id", clipId)
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 0)
.attr("height", dimensions.height);
return clipRect;
}
@@ -77,13 +77,13 @@ export function createClipPath(
export function animateClipReveal(
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
targetWidth: number,
config: AnimationConfig
config: AnimationConfig,
): void {
clipRect
.transition()
.duration(config.duration)
.ease(config.easing || d3.easeCubicInOut)
.attr('width', targetWidth);
.attr("width", targetWidth);
}
/**
@@ -102,48 +102,48 @@ export function createRoundedRectPath(
width: number,
height: number,
radius: number,
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {}
corners: { topLeft?: boolean; topRight?: boolean; bottomLeft?: boolean; bottomRight?: boolean } = {},
): string {
const { topLeft = true, topRight = true, bottomLeft = true, bottomRight = true } = corners;
if (width <= 0 || height <= 0) return '';
if (width <= 0 || height <= 0) return "";
const topLeftRadius = topLeft ? radius : 0;
const topRightRadius = topRight ? radius : 0;
const bottomRightRadius = bottomRight ? radius : 0;
const bottomLeftRadius = bottomLeft ? radius : 0;
let path = `M ${x + topLeftRadius} ${y}`;
if (topRight) {
path += ` L ${x + width - topRightRadius} ${y}`;
path += ` A ${topRightRadius} ${topRightRadius} 0 0 1 ${x + width} ${y + topRightRadius}`;
} else {
path += ` L ${x + width} ${y}`;
}
if (bottomRight) {
path += ` L ${x + width} ${y + height - bottomRightRadius}`;
path += ` A ${bottomRightRadius} ${bottomRightRadius} 0 0 1 ${x + width - bottomRightRadius} ${y + height}`;
} else {
path += ` L ${x + width} ${y + height}`;
}
if (bottomLeft) {
path += ` L ${x + bottomLeftRadius} ${y + height}`;
path += ` A ${bottomLeftRadius} ${bottomLeftRadius} 0 0 1 ${x} ${y + height - bottomLeftRadius}`;
} else {
path += ` L ${x} ${y + height}`;
}
if (topLeft) {
path += ` L ${x} ${y + topLeftRadius}`;
path += ` A ${topLeftRadius} ${topLeftRadius} 0 0 1 ${x + topLeftRadius} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
return path + ' Z';
return path + " Z";
}
/**
@@ -162,10 +162,7 @@ export function createScale(domain: [number, number], range: [number, number]) {
* @param wait The wait time in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
export function debounce<T extends (...args: unknown[]) => unknown>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
@@ -13,18 +13,17 @@ export interface ThemeInfo {
* @returns ThemeInfo object with theme detection results
*/
export function detectTheme(): ThemeInfo {
const rootEl = typeof document !== 'undefined' ? document.documentElement : null;
const schemeAttr = rootEl?.getAttribute('data-mantine-color-scheme');
const prefersDark = typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = schemeAttr ? schemeAttr === 'dark' : prefersDark;
const rootEl = typeof document !== "undefined" ? document.documentElement : null;
const schemeAttr = rootEl?.getAttribute("data-mantine-color-scheme");
const prefersDark =
typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = schemeAttr ? schemeAttr === "dark" : prefersDark;
return {
isDark,
schemeAttr,
prefersDark
prefersDark,
};
}
@@ -35,12 +34,12 @@ export function detectTheme(): ThemeInfo {
*/
export function getChartThemeVars(isDark: boolean) {
return {
background: 'var(--bg-surface)',
textPrimary: 'var(--text-primary)',
border: isDark ? '1px solid var(--border-subtle)' : '1px solid transparent',
boxShadow: isDark ? 'none' : 'var(--shadow-md)',
inactive: 'var(--usage-inactive)',
cardBorder: 'var(--api-keys-card-border)'
background: "var(--bg-surface)",
textPrimary: "var(--text-primary)",
border: isDark ? "1px solid var(--border-subtle)" : "1px solid transparent",
boxShadow: isDark ? "none" : "var(--shadow-md)",
inactive: "var(--usage-inactive)",
cardBorder: "var(--api-keys-card-border)",
};
}
@@ -51,13 +50,13 @@ export function getChartThemeVars(isDark: boolean) {
*/
export function applyTooltipStyles(tooltipElement: HTMLElement, isDark: boolean) {
const themeVars = getChartThemeVars(isDark);
tooltipElement.style.background = themeVars.background;
tooltipElement.style.color = themeVars.textPrimary;
tooltipElement.style.border = themeVars.border;
tooltipElement.style.boxShadow = themeVars.boxShadow;
tooltipElement.style.padding = '8px 10px';
tooltipElement.style.fontSize = '12px';
tooltipElement.style.lineHeight = '1.25';
tooltipElement.style.borderRadius = '8px';
tooltipElement.style.padding = "8px 10px";
tooltipElement.style.fontSize = "12px";
tooltipElement.style.lineHeight = "1.25";
tooltipElement.style.borderRadius = "8px";
}
@@ -2,7 +2,7 @@
* Utility functions for tooltip positioning and management in D3 charts
*/
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export type TooltipPosition = "top" | "bottom" | "left" | "right";
export interface TooltipPositioner {
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
@@ -19,36 +19,35 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
const bounds = container.getBoundingClientRect();
const offsetX = event.clientX - bounds.left;
const offsetY = event.clientY - bounds.top;
// Get tooltip dimensions after content is set
const tooltipHeight = tooltip.offsetHeight;
const tooltipWidth = tooltip.offsetWidth;
const gap = 16; // 1rem gap
// Position tooltip based on the specified position
switch (position) {
case 'top':
case "top":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY - tooltipHeight - gap}px`;
break;
case 'bottom':
case "bottom":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, Math.max(10, offsetX - tooltipWidth / 2))}px`;
tooltip.style.top = `${offsetY + gap}px`;
break;
case 'left':
case "left":
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
case 'right':
case "right":
tooltip.style.left = `${Math.min(bounds.width - tooltipWidth - 10, offsetX + gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
}
};
const hideTooltip = (tooltip: HTMLElement) => {
tooltip.style.opacity = '0';
tooltip.style.opacity = "0";
};
return { positionTooltip, hideTooltip };
@@ -60,15 +59,15 @@ export function createTooltipPositioner(position: TooltipPosition): TooltipPosit
* @returns The created tooltip element
*/
export function createTooltipElement(container: HTMLElement): HTMLElement {
const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.left = '0';
tooltip.style.top = '0';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.transition = 'opacity 120ms ease';
tooltip.style.zIndex = '1000';
const tooltip = document.createElement("div");
tooltip.style.position = "absolute";
tooltip.style.left = "0";
tooltip.style.top = "0";
tooltip.style.pointerEvents = "none";
tooltip.style.opacity = "0";
tooltip.style.transition = "opacity 120ms ease";
tooltip.style.zIndex = "1000";
container.appendChild(tooltip);
return tooltip;
}
@@ -1,9 +1,9 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal, Button, Stack, Slider, Alert, Text, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import Cropper from 'react-easy-crop';
import { getCroppedImage, type Area } from '@app/utils/cropImage';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import React, { useState, useCallback, useEffect } from "react";
import { Modal, Button, Stack, Slider, Alert, Text, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import Cropper from "react-easy-crop";
import { getCroppedImage, type Area } from "@app/utils/cropImage";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ProfilePictureCropperProps {
file: File | null;
@@ -12,12 +12,7 @@ interface ProfilePictureCropperProps {
onCropComplete: (croppedBlob: Blob) => void;
}
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
file,
opened,
onClose,
onCropComplete,
}) => {
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({ file, opened, onClose, onCropComplete }) => {
const { t } = useTranslation();
// State management
@@ -41,7 +36,12 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
setError(null);
};
reader.onerror = () => {
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
setError(
t(
"config.account.profilePicture.cropper.invalidImage",
"Invalid image file. Please select a valid PNG, JPG, or WebP file.",
),
);
};
reader.readAsDataURL(file);
@@ -75,12 +75,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
}, []);
// Called when crop is complete (stores the crop area in pixels)
const onCropCompleteCallback = useCallback(
(_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
const onCropCompleteCallback = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
// Process and save the cropped image
const handleSave = async () => {
@@ -100,9 +97,9 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
if (croppedBlob.size > maxSize) {
setError(
t(
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
'Cropped image is too large. Please zoom out or crop a smaller area.'
)
"config.account.profilePicture.cropper.sizeErrorAfterCrop",
"Cropped image is too large. Please zoom out or crop a smaller area.",
),
);
setProcessing(false);
return;
@@ -112,13 +109,8 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
onCropComplete(croppedBlob);
onClose();
} catch (err) {
console.error('Error cropping image:', err);
setError(
t(
'config.account.profilePicture.cropper.cropError',
'Failed to crop image. Please try again.'
)
);
console.error("Error cropping image:", err);
setError(t("config.account.profilePicture.cropper.cropError", "Failed to crop image. Please try again."));
} finally {
setProcessing(false);
}
@@ -128,7 +120,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
<Modal
opened={opened}
onClose={onClose}
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
title={t("config.account.profilePicture.cropper.title", "Crop Profile Picture")}
size="lg"
centered
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
@@ -141,7 +133,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
)}
{/* Cropper area */}
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
<Box style={{ position: "relative", width: "100%", height: 400 }}>
{imageSrc && (
<Cropper
image={imageSrc}
@@ -158,27 +150,20 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
{/* Zoom slider */}
<Stack gap={4}>
<Text size="sm" fw={500}>
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
{t("config.account.profilePicture.cropper.zoom", "Zoom")}
</Text>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
onChange={setZoom}
disabled={processing}
/>
<Slider value={zoom} min={1} max={3} step={0.1} onChange={setZoom} disabled={processing} />
</Stack>
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<Button variant="subtle" onClick={onClose} disabled={processing}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={handleSave} loading={processing}>
{processing
? t('config.account.profilePicture.cropper.processing', 'Processing crop...')
: t('config.account.profilePicture.cropper.save', 'Save Cropped Image')}
? t("config.account.profilePicture.cropper.processing", "Processing crop...")
: t("config.account.profilePicture.cropper.save", "Save Cropped Image")}
</Button>
</div>
</Stack>
@@ -40,7 +40,7 @@ export default function ApiKeys() {
};
const goToAccount = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
window.dispatchEvent(new CustomEvent("appConfig:navigate", { detail: { key: "overview" } }));
};
const showUsage = Boolean(credits);
@@ -48,67 +48,83 @@ export default function ApiKeys() {
return (
<Stack gap={20} p={0}>
{showUsage && (
<UsageSection
<UsageSection
apiUsage={credits!}
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
overlayMessage={t("config.apiKeys.overlayMessage", "Generate a key to see credits and available credits")}
loading={creditsLoading}
/>
)}
{!isAnonymous && apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
{t("config.apiKeys.generateError", "We couldn't generate your API key.")}{" "}
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
{t('common.retry', 'Retry')}
{t("common.retry", "Retry")}
</Anchor>
</Text>
)}
{isAnonymous ? (
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Stack gap={10}>
<Text fw={500}>{t('config.apiKeys.label', 'API Key')}</Text>
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: '1rem' }}>
<Text fw={500}>{t("config.apiKeys.label", "API Key")}</Text>
<Group justify="space-between" wrap="nowrap" align="center" style={{ gap: "1rem" }}>
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
{t('config.apiKeys.guestInfo', 'Guest users do not receive API keys. Create an account to get an API key you can use in your applications.')}
{t(
"config.apiKeys.guestInfo",
"Guest users do not receive API keys. Create an account to get an API key you can use in your applications.",
)}
</Text>
<Button size="sm" onClick={goToAccount}>
{t('config.apiKeys.goToAccount', 'Go to Account')}
{t("config.apiKeys.goToAccount", "Go to Account")}
</Button>
</Group>
</Stack>
</Paper>
) : apiKeyLoading ? (
<>
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
{t(
"config.apiKeys.description",
"Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
)}
</Text>
<div
style={{
padding: 18,
borderRadius: 12,
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="center" gap={12} wrap="nowrap">
<SkeletonLoader type="block" width="100%" height={36} />
<SkeletonLoader type="block" width={76} height={32} />
<SkeletonLoader type="block" width={92} height={32} />
</Group>
</div>
</>
) : (
apiKeyLoading ? (
<>
<Text size="sm" c="dimmed" style={{ marginBottom: 8 }}>
{t('config.apiKeys.description', "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.")}
</Text>
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Group align="center" gap={12} wrap="nowrap">
<SkeletonLoader type="block" width="100%" height={36} />
<SkeletonLoader type="block" width={76} height={32} />
<SkeletonLoader type="block" width={92} height={32} />
</Group>
</div>
</>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)}
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
</Stack>
);
}
@@ -1,17 +1,30 @@
import React, { useState } from 'react';
import { Alert, Avatar, Button, Divider, FileButton, Group, Image, LoadingOverlay, PasswordInput, Text, TextInput, Modal } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from '@app/auth/supabase';
import { BASE_PATH } from '@app/constants/app';
import { oauthProviders } from '@app/constants/authProviders';
import { Tooltip } from '@app/components/shared/Tooltip';
import { absoluteWithBasePath } from '@app/constants/app';
import { synchronizeUserUpgrade } from '@app/services/userService';
import { ProfilePictureCropper } from '@app/components/shared/config/ProfilePictureCropper';
import { updateProfilePictureMetadata } from '@app/services/avatarSyncService';
import { deleteCurrentAccount } from '@app/services/accountDeletion';
import { alert as showToast } from '@app/components/toast';
import React, { useState } from "react";
import {
Alert,
Avatar,
Button,
Divider,
FileButton,
Group,
Image,
LoadingOverlay,
PasswordInput,
Text,
TextInput,
Modal,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous, linkEmailIdentity, linkOAuthIdentity, supabase } from "@app/auth/supabase";
import { BASE_PATH } from "@app/constants/app";
import { oauthProviders } from "@app/constants/authProviders";
import { Tooltip } from "@app/components/shared/Tooltip";
import { absoluteWithBasePath } from "@app/constants/app";
import { synchronizeUserUpgrade } from "@app/services/userService";
import { ProfilePictureCropper } from "@app/components/shared/config/ProfilePictureCropper";
import { updateProfilePictureMetadata } from "@app/services/avatarSyncService";
import { deleteCurrentAccount } from "@app/services/accountDeletion";
import { alert as showToast } from "@app/components/toast";
interface OverviewProps {
onLogoutClick: () => void;
@@ -19,12 +32,20 @@ interface OverviewProps {
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const { t } = useTranslation();
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
const {
user,
refreshSession,
signOut,
profilePictureUrl,
profilePictureMetadata,
refreshProfilePicture,
refreshProfilePictureMetadata,
} = useAuth();
const PROFILE_BUCKET = 'profile-pictures';
const PROFILE_BUCKET = "profile-pictures";
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [upgradeError, setUpgradeError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -34,14 +55,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const [cropperOpen, setCropperOpen] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [confirmEmail, setConfirmEmail] = useState('');
const [confirmEmail, setConfirmEmail] = useState("");
const isAnonymous = Boolean(user && isUserAnonymous(user));
const isOAuthPicture = profilePictureMetadata?.source === 'oauth';
const isOAuthPicture = profilePictureMetadata?.source === "oauth";
const provider = profilePictureMetadata?.provider;
const profilePath = user ? `${user.id}/avatar` : null;
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U';
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
const handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
@@ -49,7 +70,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
}
if (file.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
return;
}
@@ -66,7 +87,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
// Validate cropped size (2MB limit)
if (croppedBlob.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setProfileError(t("config.account.profilePicture.sizeError", "Please select an image smaller than 2MB."));
setCropperOpen(false);
setCropperFile(null);
return;
@@ -75,21 +96,18 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.upload(profilePath, croppedBlob, {
upsert: true,
cacheControl: '3600',
contentType: 'image/png'
});
const { error } = await supabase.storage.from(PROFILE_BUCKET).upload(profilePath, croppedBlob, {
upsert: true,
cacheControl: "3600",
contentType: "image/png",
});
if (error) {
setProfileError(error.message || 'Failed to upload profile picture');
setProfileError(error.message || "Failed to upload profile picture");
} else {
// Mark as manual upload in metadata
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
@@ -109,17 +127,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.remove([profilePath]);
const { error } = await supabase.storage.from(PROFILE_BUCKET).remove([profilePath]);
if (error) {
setProfileError(error.message || 'Failed to remove profile picture');
setProfileError(error.message || "Failed to remove profile picture");
} else {
// Clear metadata when removing picture
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
@@ -140,17 +155,19 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
try {
// Update metadata to allow manual uploads
await updateProfilePictureMetadata(user.id, {
source: 'upload',
source: "upload",
provider: null,
});
await refreshProfilePictureMetadata();
setSuccess(t('config.account.profilePicture.switchedToCustom', 'Switched to custom picture. You can now upload your own.'));
setSuccess(
t("config.account.profilePicture.switchedToCustom", "Switched to custom picture. You can now upload your own."),
);
// Clear success message after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (error: unknown) {
setProfileError(error instanceof Error ? error.message : 'Failed to switch to custom picture');
setProfileError(error instanceof Error ? error.message : "Failed to switch to custom picture");
} finally {
setProfileUploading(false);
}
@@ -160,7 +177,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
e.preventDefault();
if (!email.trim()) {
setUpgradeError('Email is required');
setUpgradeError("Email is required");
return;
}
@@ -173,34 +190,34 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
await linkEmailIdentity(email.trim(), password || undefined);
// Synchronize with backend database (using "email" as auth method for email/password)
await synchronizeUserUpgrade('email');
await synchronizeUserUpgrade("email");
// Refresh the session to reflect changes
await refreshSession();
setSuccess('Account upgraded successfully! You can now sign in with your email.');
setEmail('');
setPassword('');
setSuccess("Account upgraded successfully! You can now sign in with your email.");
setEmail("");
setPassword("");
} catch (err: unknown) {
setUpgradeError(err instanceof Error ? err.message : 'Failed to upgrade account');
setUpgradeError(err instanceof Error ? err.message : "Failed to upgrade account");
} finally {
setIsLoading(false);
}
};
const handleOAuthUpgrade = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const handleOAuthUpgrade = async (provider: "github" | "google" | "apple" | "azure") => {
try {
setIsLoading(true);
setUpgradeError(null);
setSuccess(null);
// Store provider info for post-redirect handling
sessionStorage.setItem('pendingUpgrade', 'true');
sessionStorage.setItem('upgradeProvider', provider);
sessionStorage.setItem("pendingUpgrade", "true");
sessionStorage.setItem("upgradeProvider", provider);
// Redirect back to homepage after OAuth completes
// The UseSession hook will handle the pendingUpgrade synchronization
const redirectUrl = absoluteWithBasePath('/auth/callback?next=/');
const redirectUrl = absoluteWithBasePath("/auth/callback?next=/");
const result = await linkOAuthIdentity(provider, redirectUrl);
if (result.data?.url) {
@@ -210,32 +227,31 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
setUpgradeError(errorMessage);
setIsLoading(false);
sessionStorage.removeItem('pendingUpgrade');
sessionStorage.removeItem('upgradeProvider');
sessionStorage.removeItem("pendingUpgrade");
sessionStorage.removeItem("upgradeProvider");
}
};
const handleDeleteAccount = async () => {
if (isAnonymous) return;
try {
setIsDeletingAccount(true);
await deleteCurrentAccount();
setDeleteModalOpen(false);
setConfirmEmail('');
setConfirmEmail("");
await signOut();
window.location.href = absoluteWithBasePath('/login');
window.location.href = absoluteWithBasePath("/login");
} catch (err) {
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
const fallbackMessage = t("config.account.overview.deleteFailed", "Failed to delete account.");
const message = err instanceof Error ? err.message : fallbackMessage;
console.error('[Overview] Delete account failed:', err);
console.error("[Overview] Delete account failed:", err);
showToast({
alertType: 'error',
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
alertType: "error",
title: t("config.account.overview.deleteFailedTitle", "Unable to delete account"),
body: message,
expandable: true,
location: 'top-right',
durationMs: 7000
location: "top-right",
durationMs: 7000,
});
} finally {
setIsDeletingAccount(false);
@@ -244,33 +260,35 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setConfirmEmail('');
setConfirmEmail("");
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem", position: "relative" }}>
<LoadingOverlay visible={isLoading || isDeletingAccount} />
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.overview.title', 'Account Settings')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.overview.title", "Account Settings")}
</h3>
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
<p style={{ margin: "0.25rem 0 0 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{isAnonymous
? t('config.account.overview.guestDescription', 'You are signed in as a guest. Consider upgrading your account below.')
: t('config.account.overview.manageAccountPreferences', 'Manage your account preferences')
}
? t(
"config.account.overview.guestDescription",
"You are signed in as a guest. Consider upgrading your account below.",
)
: t("config.account.overview.manageAccountPreferences", "Manage your account preferences")}
</p>
{user?.email && (
<p style={{ margin: '0.25rem 0 0 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('config.account.overview.signedInAs', 'Signed in as')}: {user.email}
<p style={{ margin: "0.25rem 0 0 0", color: "var(--mantine-color-dimmed)", fontSize: "0.75rem" }}>
{t("config.account.overview.signedInAs", "Signed in as")}: {user.email}
</p>
)}
</div>
<Button color="red" variant="filled" onClick={onLogoutClick}>
{t('logOut', 'Log out')}
{t("logOut", "Log out")}
</Button>
</div>
</div>
@@ -278,11 +296,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Divider />
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.profilePicture.title', 'Profile picture')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.profilePicture.title", "Profile picture")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.profilePicture.description', 'Upload an image to personalize your account.')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.profilePicture.description", "Upload an image to personalize your account.")}
</p>
{profileError && (
@@ -302,14 +320,14 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<Text size="sm" c="dimmed">
{t('config.account.profilePicture.usingProvider', 'Using {{provider}} profile picture', {
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'OAuth'
{t("config.account.profilePicture.usingProvider", "Using {{provider}} profile picture", {
provider: provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : "OAuth",
})}
</Text>
<Button variant="outline" onClick={handleUseCustomPicture} disabled={profileUploading}>
{t('config.account.profilePicture.useCustom', 'Use custom picture')}
{t("config.account.profilePicture.useCustom", "Use custom picture")}
</Button>
</div>
</Group>
@@ -318,7 +336,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<Group gap="sm">
<FileButton
onChange={handleProfileUpload}
@@ -327,20 +345,16 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
>
{(props) => (
<Button {...props} loading={profileUploading}>
{t('config.account.profilePicture.upload', 'Upload')}
{t("config.account.profilePicture.upload", "Upload")}
</Button>
)}
</FileButton>
<Button
variant="outline"
onClick={handleProfileRemove}
disabled={!profilePictureUrl || profileUploading}
>
{t('config.account.profilePicture.remove', 'Remove')}
<Button variant="outline" onClick={handleProfileRemove} disabled={!profilePictureUrl || profileUploading}>
{t("config.account.profilePicture.remove", "Remove")}
</Button>
</Group>
<Text size="xs" c="var(--mantine-color-dimmed)">
{t('config.account.profilePicture.help', 'PNG, JPG, or WebP up to 2MB.')}
{t("config.account.profilePicture.help", "PNG, JPG, or WebP up to 2MB.")}
</Text>
</div>
</Group>
@@ -362,11 +376,11 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
{isAnonymous && (
<div>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.upgrade.title', 'Upgrade Guest Account')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.upgrade.title", "Upgrade Guest Account")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.upgrade.description', 'Link your account to preserve your history and access more features!')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.upgrade.description", "Link your account to preserve your history and access more features!")}
</p>
</div>
@@ -382,17 +396,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
</Alert>
)}
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: "1rem" }}>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.socialLogin', 'Upgrade with Social Account')}
{t("config.account.upgrade.socialLogin", "Upgrade with Social Account")}
</Text>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{oauthProviders
.filter(provider => !provider.isDisabled)
.filter((provider) => !provider.isDisabled)
.map((provider) => (
<Tooltip
key={provider.id}
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`}
>
<Button
variant="outline"
@@ -404,7 +418,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
style={{ width: 16, height: 16 }}
/>
}
onClick={() => handleOAuthUpgrade(provider.id as 'github' | 'google' | 'apple' | 'azure')}
onClick={() => handleOAuthUpgrade(provider.id as "github" | "google" | "apple" | "azure")}
disabled={isLoading}
>
{provider.label}
@@ -416,28 +430,28 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<div>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{t('config.account.upgrade.emailPassword', 'or enter your email & password')}
{t("config.account.upgrade.emailPassword", "or enter your email & password")}
</Text>
<form onSubmit={handleEmailUpgrade}>
<Group align="end" gap="sm">
<TextInput
label={t('config.account.upgrade.email', 'Email')}
placeholder={t('config.account.upgrade.emailPlaceholder', 'Enter your email')}
label={t("config.account.upgrade.email", "Email")}
placeholder={t("config.account.upgrade.emailPlaceholder", "Enter your email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ flex: 1 }}
/>
<PasswordInput
label={t('config.account.upgrade.password', 'Password (optional)')}
placeholder={t('config.account.upgrade.passwordPlaceholder', 'Set a password')}
label={t("config.account.upgrade.password", "Password (optional)")}
placeholder={t("config.account.upgrade.passwordPlaceholder", "Set a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
description={t('config.account.upgrade.passwordNote', 'Leave empty to use email verification only')}
description={t("config.account.upgrade.passwordNote", "Leave empty to use email verification only")}
style={{ flex: 1 }}
/>
<Button type="submit" disabled={isLoading}>
{t('config.account.upgrade.upgradeButton', 'Upgrade Account')}
{t("config.account.upgrade.upgradeButton", "Upgrade Account")}
</Button>
</Group>
</form>
@@ -447,19 +461,17 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
{/* Delete Account Section */}
{!isAnonymous && (
<div style={{
marginTop: 'auto',
paddingTop: '1.5rem',
display: 'flex',
justifyContent: 'flex-end',
borderTop: '1px solid var(--mantine-color-default-border)'
}}>
<Button
color="red"
variant="outline"
onClick={() => setDeleteModalOpen(true)}
>
{t('config.account.overview.deleteAccount', 'Delete Account')}
<div
style={{
marginTop: "auto",
paddingTop: "1.5rem",
display: "flex",
justifyContent: "flex-end",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Button color="red" variant="outline" onClick={() => setDeleteModalOpen(true)}>
{t("config.account.overview.deleteAccount", "Delete Account")}
</Button>
</div>
)}
@@ -468,34 +480,40 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Modal
opened={deleteModalOpen}
onClose={closeDeleteModal}
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
title={t("config.account.overview.deleteAccountTitle", "Delete Account")}
centered
zIndex={10000}
>
<form onSubmit={(e) => {
e.preventDefault();
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
handleDeleteAccount();
}
}}>
<form
onSubmit={(e) => {
e.preventDefault();
if (confirmEmail.toLowerCase() === user?.email?.toLowerCase()) {
handleDeleteAccount();
}
}}
>
<Text size="sm" mb="md">
{t('config.account.overview.deleteWarning',
'This action is permanent and cannot be undone. All your data will be deleted.')}
{t(
"config.account.overview.deleteWarning",
"This action is permanent and cannot be undone. All your data will be deleted.",
)}
</Text>
<Text size="sm" fw={500} mb="xs">
{t('config.account.overview.enterEmailConfirm',
'To confirm deletion, please type your email address ({{email}}) below:',
{ email: user?.email })}
{t(
"config.account.overview.enterEmailConfirm",
"To confirm deletion, please type your email address ({{email}}) below:",
{ email: user?.email },
)}
</Text>
<TextInput
placeholder={user?.email || ''}
placeholder={user?.email || ""}
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
mb="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDeleteModal} type="button">
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
@@ -503,7 +521,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
type="submit"
loading={isDeletingAccount}
>
{t('config.account.overview.confirmDelete', 'Delete My Account')}
{t("config.account.overview.confirmDelete", "Delete My Account")}
</Button>
</Group>
</form>
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
import React, { useState } from "react";
import { Button, PasswordInput, Group, Alert, LoadingOverlay, Modal, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { supabase } from "@app/auth/supabase";
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
const PasswordSecurity: React.FC = () => {
const { t } = useTranslation();
@@ -11,23 +11,23 @@ const PasswordSecurity: React.FC = () => {
const [opened, setOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [didUpdate, setDidUpdate] = useState(false);
const handleChangePassword = async () => {
if (!newPassword || !confirmPassword) {
setError(t('signup.pleaseFillAllFields', 'Please fill in all fields'));
setError(t("signup.pleaseFillAllFields", "Please fill in all fields"));
return;
}
if (newPassword.length < 6) {
setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long'));
setError(t("signup.passwordTooShort", "Password must be at least 6 characters long"));
return;
}
if (newPassword !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
setError(t("signup.passwordsDoNotMatch", "Passwords do not match"));
return;
}
@@ -43,9 +43,9 @@ const PasswordSecurity: React.FC = () => {
return;
}
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
setNewPassword('');
setConfirmPassword('');
setSuccess(t("login.passwordUpdatedSuccess", "Your password has been updated successfully."));
setNewPassword("");
setConfirmPassword("");
setDidUpdate(true);
// Replace form with success text, then close after 2s
@@ -56,48 +56,58 @@ const PasswordSecurity: React.FC = () => {
setDidUpdate(false);
}, 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to change password');
setError(e instanceof Error ? e.message : "Failed to change password");
} finally {
setIsLoading(false);
}
};
return (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<LoadingOverlay visible={isLoading} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('config.account.security.title', 'Passwords & Security')}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('config.account.security.description', 'Manage your password and security settings.')}
</p>
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("config.account.security.title", "Passwords & Security")}
</h3>
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("config.account.security.description", "Manage your password and security settings.")}
</p>
</div>
<Button type="button" onClick={() => setOpened(true)} variant="filled">
{t('config.account.security.changePassword', 'Change password')}
{t("config.account.security.changePassword", "Change password")}
</Button>
</div>
<Modal opened={opened} onClose={() => setOpened(false)} centered title={t('config.account.security.changePassword', 'Change password')} zIndex={Z_INDEX_OVER_SETTINGS_MODAL}>
<Modal
opened={opened}
onClose={() => setOpened(false)}
centered
title={t("config.account.security.changePassword", "Change password")}
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
{error && (
<Alert color="red" mb="md">{error}</Alert>
<Alert color="red" mb="md">
{error}
</Alert>
)}
{didUpdate ? (
<Alert color="green" mb="md">{success || t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.')}</Alert>
<Alert color="green" mb="md">
{success || t("login.passwordUpdatedSuccess", "Your password has been updated successfully.")}
</Alert>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<PasswordInput
label={t('account.newPassword', 'New Password')}
placeholder={t('account.newPassword', 'New Password')}
label={t("account.newPassword", "New Password")}
placeholder={t("account.newPassword", "New Password")}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
<PasswordInput
label={t('account.confirmNewPassword', 'Confirm New Password')}
placeholder={t('account.confirmNewPassword', 'Confirm New Password')}
label={t("account.confirmNewPassword", "Confirm New Password")}
placeholder={t("account.confirmNewPassword", "Confirm New Password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
@@ -105,10 +115,10 @@ const PasswordSecurity: React.FC = () => {
<Divider my="sm" />
<Group justify="flex-end">
<Button type="button" variant="default" onClick={() => setOpened(false)}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
{t('config.account.security.update', 'Update password')}
{t("config.account.security.update", "Update password")}
</Button>
</Group>
</div>
@@ -119,5 +129,3 @@ const PasswordSecurity: React.FC = () => {
};
export default PasswordSecurity;
@@ -1,76 +1,85 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core';
import { usePlans, PlanTier } from '@app/hooks/usePlans';
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from '@app/components/shared/StripeCheckoutSaas';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import ApiPackagesSection from '@app/components/shared/config/configSections/plan/ApiPackagesSection';
import ActivePlanSection from '@app/components/shared/config/configSections/plan/ActivePlanSection';
import { useAuth } from '@app/auth/UseSession';
import React, { useState, useCallback, useEffect } from "react";
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
import { usePlans, PlanTier } from "@app/hooks/usePlans";
import StripeCheckout, { PurchaseType, CreditsPack, PlanID } from "@app/components/shared/StripeCheckoutSaas";
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
import { useAuth } from "@app/auth/UseSession";
const Plan: React.FC = () => {
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
const [purchaseType, setPurchaseType] = useState<PurchaseType>('subscription');
const [purchaseType, setPurchaseType] = useState<PurchaseType>("subscription");
const [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
const [currency, setCurrency] = useState<string>('gbp');
const [currency, setCurrency] = useState<string>("gbp");
const { trialStatus } = useAuth();
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
const currencyOptions = [
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
{ value: 'gbp', label: 'British pound (GBP, £)' }
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
{ value: "gbp", label: "British pound (GBP, £)" },
];
const handleUpgradeClick = useCallback((plan: PlanTier) => {
if (!data) return;
const handleUpgradeClick = useCallback(
(plan: PlanTier) => {
if (!data) return;
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open('mailto:[email protected]?subject=Enterprise Plan Inquiry', '_blank');
return;
}
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open("mailto:[email protected]?subject=Enterprise Plan Inquiry", "_blank");
return;
}
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
},
[data],
);
const handleCreditPurchaseClick = useCallback((creditsPack: CreditsPack) => {
if (!data) return;
const handleCreditPurchaseClick = useCallback(
(creditsPack: CreditsPack) => {
if (!data) return;
setSelectedCreditsPack(creditsPack);
setPurchaseType('credits');
setSelectedPlan(null);
setCheckoutOpen(true);
}, [data]);
const handlePaymentSuccess = useCallback((sessionId: string) => {
console.log('Payment successful, session:', sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === 'subscription') {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedCreditsPack(creditsPack);
setPurchaseType("credits");
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
}, [selectedPlan, purchaseType, updateCurrentPlan]);
setCheckoutOpen(true);
},
[data],
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log("Payment successful, session:", sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === "subscription") {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
},
[selectedPlan, purchaseType, updateCurrentPlan],
);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
console.error("Payment error:", error);
// Error is already displayed in the StripeCheckout component
}, []);
@@ -84,11 +93,11 @@ const Plan: React.FC = () => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
const proPlan = Array.from(data.plans.values()).find((plan) => plan.id === "pro");
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType('subscription');
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
@@ -99,12 +108,12 @@ const Plan: React.FC = () => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'add-payment') {
if (params.get("action") === "add-payment") {
handleAddPaymentClick();
// Clean up URL
params.delete('action');
params.delete("action");
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
window.history.replaceState({}, "", newUrl);
}
}, [data, handleAddPaymentClick]);
@@ -137,14 +146,16 @@ const Plan: React.FC = () => {
const plansArray = Array.from(plans.values());
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>Currency</Text>
<Text size="lg" fw={600}>
Currency
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
onChange={(value) => setCurrency(value || "gbp")}
data={currencyOptions}
searchable
clearable={true}
@@ -163,11 +174,7 @@ const Plan: React.FC = () => {
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<AvailablePlansSection plans={plansArray} currentPlan={currentPlan} onUpgradeClick={handleUpgradeClick} />
<Divider />
@@ -183,16 +190,16 @@ const Plan: React.FC = () => {
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
planId={purchaseType === 'subscription' ? (selectedPlan?.id as PlanID) : null}
creditsPack={purchaseType === 'credits' ? selectedCreditsPack : null}
planId={purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : null}
creditsPack={purchaseType === "credits" ? selectedCreditsPack : null}
planName={
purchaseType === 'subscription'
? selectedPlan?.name || ''
: data?.apiPackages.find(pkg => pkg.id === selectedCreditsPack)?.name || ''
purchaseType === "subscription"
? selectedPlan?.name || ""
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)?.name || ""
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
isTrialConversion={trialStatus?.isTrialing && purchaseType === "subscription"}
/>
</div>
);
@@ -1,13 +1,7 @@
import React from "react";
import {
Paper,
Stack,
Group,
Text,
Divider,
} from "@mantine/core";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
import { FractionData } from '@app/types/charts';
import { FractionData } from "@app/types/charts";
import { ApiCredits as ApiUsage } from "@app/types/credits";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { formatUTC } from "@app/components/shared/utils/date";
@@ -26,7 +20,7 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
const { t } = useTranslation();
const weeklyUsed = apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
const boughtUsed = apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
// Totals for overall usage visualization
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
@@ -35,42 +29,49 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
// Prepare data for the stacked bar chart
const fractions: FractionData[] = [
{
name: t('config.apiKeys.includedCredits', 'Included credits'),
name: t("config.apiKeys.includedCredits", "Included credits"),
numerator: Math.max(0, weeklyUsed),
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-weekly-active)"
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-weekly-active)",
},
{
name: t('config.apiKeys.purchasedCredits', 'Purchased credits'),
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
numerator: Math.max(0, boughtUsed),
denominator: Math.max(0, apiUsage.totalBoughtCredits),
numeratorLabel: t('common.used', 'used'),
denominatorLabel: t('common.available', 'available'),
color: "var(--usage-bought-active)"
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-bought-active)",
},
];
return (
<div style={{ position: "relative" }}>
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Stack gap={12}
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Stack
gap={12}
style={{
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
fontFamily:
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
}}
>
<Group justify="space-between">
<Text fw={500}>
{t('config.apiKeys.creditsRemaining', 'Credits Remaining')}: {loading ? (
<SkeletonLoader type="block" width={40} height={14} />
) : (
totalRemaining
)}
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
{loading ? <SkeletonLoader type="block" width={40} height={14} /> : totalRemaining}
</Text>
</Group>
<StackedBarChart
<StackedBarChart
fractions={fractions}
width={640}
height={22}
@@ -79,12 +80,13 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
loading={Boolean(loading)}
animate={!loading}
animationDurationMs={900}
ariaLabel={t('config.apiKeys.chartAriaLabel', {
ariaLabel={t("config.apiKeys.chartAriaLabel", {
includedUsed: Math.max(0, weeklyUsed),
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
purchasedUsed: Math.max(0, boughtUsed),
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
defaultValue: 'Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}'
defaultValue:
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
})}
/>
@@ -93,18 +95,16 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
<Group justify="space-between" wrap="wrap">
<Group gap="lg">
<Text size="sm" c="dimmed">
{t('config.apiKeys.nextReset', 'Next Reset')}: {loading ? (
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
{loading ? (
<SkeletonLoader type="block" width={120} height={12} />
) : (
formatDate(apiUsage.weeklyResetDate, false)
)}
</Text>
<Text size="sm" c="dimmed">
{t('config.apiKeys.lastApiUse', 'Last API Use')}: {loading ? (
<SkeletonLoader type="block" width={160} height={12} />
) : (
formatDate(apiUsage.lastApiUsage, true)
)}
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
{loading ? <SkeletonLoader type="block" width={160} height={12} /> : formatDate(apiUsage.lastApiUsage, true)}
</Text>
</Group>
</Group>
@@ -130,10 +130,10 @@ export default function UsageSection({ apiUsage, obscured, overlayMessage, loadi
}}
>
<Text size="sm" c="dimmed">
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
{overlayMessage || t("config.apiKeys.overlayMessage", "Generate a key to see credits and available credits")}
</Text>
</div>
)}
</div>
);
}
}
@@ -66,5 +66,3 @@ export function useApiKey() {
}
export default useApiKey;
@@ -12,25 +12,13 @@ function coerceNumber(value: unknown, fallback = 0): number {
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left
),
weeklyCreditsAllocated: coerceNumber(
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total
),
boughtCreditsRemaining: coerceNumber(
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left
),
totalBoughtCredits: coerceNumber(
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total
),
totalAvailableCredits: coerceNumber(
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total
),
weeklyResetDate:
String(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? ""),
lastApiUsage:
String(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? ""),
weeklyCreditsRemaining: coerceNumber(raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left),
weeklyCreditsAllocated: coerceNumber(raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total),
boughtCreditsRemaining: coerceNumber(raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left),
totalBoughtCredits: coerceNumber(raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total),
totalAvailableCredits: coerceNumber(raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total),
weeklyResetDate: String(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? ""),
lastApiUsage: String(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? ""),
};
}
@@ -49,13 +37,14 @@ export function useCredits() {
const res = await apiClient.get<Record<string, unknown>>("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty = !normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
const isEmpty =
!normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
@@ -75,5 +64,3 @@ export function useCredits() {
}
export default useCredits;
@@ -1,10 +1,10 @@
import React from 'react';
import { Card, Text, Group, Flex, Alert, Button, Badge } from '@mantine/core';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CreditCardIcon from '@mui/icons-material/CreditCard';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import React from "react";
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CreditCardIcon from "@mui/icons-material/CreditCard";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
interface TrialStatus {
isTrialing: boolean;
@@ -27,23 +27,20 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
_activeSince,
_nextBillingDate,
trialStatus,
onAddPaymentClick
onAddPaymentClick,
}) => {
const { t } = useTranslation();
return (
<div>
<Flex justify="space-between" align="center">
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.activePlan.title", "Active Plan")}
</h3>
<ManageBillingButton
returnUrl={`${window.location.origin}/account`}
trialStatus={trialStatus}
/>
<ManageBillingButton returnUrl={`${window.location.origin}/account`} trialStatus={trialStatus} />
</Flex>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.activePlan.subtitle", "Your current subscription details")}
</p>
{/* Trial Status Alert */}
@@ -53,22 +50,23 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('plan.trial.title', 'Free Trial Active')}
title={t("plan.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t('plan.trial.daysRemaining', 'Your trial ends in {{days}} days', {
days: trialStatus.daysRemaining
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
days: trialStatus.daysRemaining,
})}
</Text>
<Text size="xs" c="dimmed">
{t('plan.trial.endDate', 'Expires: {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
{t("plan.trial.endDate", "Expires: {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
{trialStatus.hasScheduledSub ? (
<Text size="xs" c="green" fw={500} mt="sm">
{t('plan.trial.subscriptionScheduled', 'Subscription scheduled - starts {{date}}', {
date: new Date(trialStatus.trialEnd).toLocaleDateString()
{" "}
{t("plan.trial.subscriptionScheduled", "Subscription scheduled - starts {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
) : (
@@ -80,7 +78,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
)
)}
@@ -96,7 +94,7 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t('plan.trial.badge', 'Trial')}
{t("plan.trial.badge", "Trial")}
</Badge>
)}
</Group>
@@ -108,7 +106,8 @@ const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}{currentPlan.price}/month
{currentPlan.currency}
{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
@@ -1,7 +1,7 @@
import React from 'react';
import { Button, Card, Text, Stack, Flex, Slider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { CreditsPack } from '@app/components/shared/StripeCheckoutSaas';
import React from "react";
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
interface ApiPackage {
id: string;
@@ -23,17 +23,17 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
onCreditPurchaseClick
onCreditPurchaseClick,
}) => {
const { t } = useTranslation();
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.apiPackages.title', 'API Credit Packages')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.apiPackages.title", "API Credit Packages")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.apiPackages.subtitle', 'Purchase API credits for your applications')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.apiPackages.subtitle", "Purchase API credits for your applications")}
</p>
<Card padding="xl" radius="md" className="mb-4">
@@ -41,7 +41,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.selectCredits', 'Select Credit Amount')}
{t("plan.selectCredits", "Select Credit Amount")}
</Text>
<div className="px-4">
@@ -53,10 +53,10 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
max={3}
step={0.01}
marks={[
{ value: 0, label: '100' },
{ value: 1, label: '500' },
{ value: 2, label: '1K' },
{ value: 3, label: '5K' }
{ value: 0, label: "100" },
{ value: 1, label: "500" },
{ value: 2, label: "1K" },
{ value: 3, label: "5K" },
]}
size="lg"
className="mb-6"
@@ -78,10 +78,11 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
{apiPackages[Math.round(selectedCredits)].currency}
{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t('plan.totalCost', 'Total Cost')}
{t("plan.totalCost", "Total Cost")}
</Text>
</div>
@@ -89,7 +90,7 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
size="lg"
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
>
{t('plan.purchase', 'Purchase')}
{t("plan.purchase", "Purchase")}
</Button>
</Flex>
</Stack>
@@ -98,4 +99,4 @@ const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
);
};
export default ApiPackagesSection;
export default ApiPackagesSection;
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import React, { useState } from "react";
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -16,27 +16,23 @@ interface AvailablePlansSectionProps {
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick
}) => {
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({ plans, currentPlan, onUpgradeClick }) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
const isUserProOrAbove = currentPlan?.id === 'pro' || currentPlan?.id === 'enterprise';
const isUserProOrAbove = currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p style={{ margin: '0.25rem 0 1rem 0', color: 'var(--mantine-color-dimmed)', fontSize: '0.875rem' }}>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
<p style={{ margin: "0.25rem 0 1rem 0", color: "var(--mantine-color-dimmed)", fontSize: "0.875rem" }}>
{t("plan.availablePlans.subtitle", "Choose the plan that fits your needs")}
</p>
<div className="flex h-[20rem] mb-4 " style={{ gap: '1rem', overflowX: 'auto' }}>
{plans.map(plan => (
<div className="flex h-[20rem] mb-4 " style={{ gap: "1rem", overflowX: "auto" }}>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
@@ -48,29 +44,25 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')
}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder className="mt-4">
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">{t('plan.feature.title', 'Feature')}</th>
{plans.map(plan => (
<th className="text-left p-2">{t("plan.feature.title", "Feature")}</th>
{plans.map((plan) => (
<th key={plan.id} className="text-center p-2 min-w-24 relative">
{plan.name}
{plan.popular && (
@@ -78,16 +70,16 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
color="blue"
variant="filled"
style={{
position: 'absolute',
top: '0rem',
right: '-2rem',
fontSize: '0.5rem',
fontWeight: '500',
height: '1rem',
padding: '0 0.1rem'
position: "absolute",
top: "0rem",
right: "-2rem",
fontSize: "0.5rem",
fontWeight: "500",
height: "1rem",
padding: "0 0.1rem",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
@@ -97,13 +89,13 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr key={featureIndex} className="border-b">
<td className="p-2">
{plans[0].features[featureIndex].name}
</td>
{plans.map(plan => (
<td className="p-2">{plans[0].features[featureIndex].name}</td>
{plans.map((plan) => (
<td key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}></Text>
<Text c="green" fw={600}>
</Text>
) : (
<Text c="gray">-</Text>
)}
@@ -1,17 +1,17 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTier } from '@app/hooks/usePlans';
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
interface PlanCardProps {
plan?: PlanTier;
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: unknown;
currentTier?: string | null; // Accept null for proprietary compatibility
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
loginEnabled?: boolean;
@@ -28,7 +28,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled
loginEnabled: _loginEnabled,
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
@@ -36,26 +36,24 @@ const PlanCard: React.FC<PlanCardProps> = ({
if (!plan) return null; // Safety check
const shouldHideUpgrade = plan.id === 'free' && isUserProOrAbove;
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
return (
<Card key={plan.id} padding="lg" radius="sm" withBorder className="h-full w-[33%] relative">
{plan.popular && (
<Badge
variant="filled"
size='xs'
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
<Badge variant="filled" size="xs" style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="md" className="h-full">
<div>
<Text size="lg" fw={600}>{plan.name}</Text>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="2xl" fw={700}>
{plan.isContactOnly ? t('plan.customPricing', 'Custom') : `${plan.currency}${plan.price}`}
{plan.isContactOnly ? t("plan.customPricing", "Custom") : `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
@@ -83,11 +81,10 @@ const PlanCard: React.FC<PlanCardProps> = ({
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
? t("plan.current", "Current Plan")
: plan.isContactOnly
? t('plan.contact', 'Get in Touch')
: t('plan.upgrade', 'Upgrade')
}
? t("plan.contact", "Get in Touch")
: t("plan.upgrade", "Upgrade")}
</Button>
)}
</Stack>
@@ -1,42 +1,42 @@
import React from 'react';
import { type TFunction } from 'i18next';
import React from "react";
import { type TFunction } from "i18next";
import {
createConfigNavSections as createCoreConfigNavSections,
type ConfigNavSection,
} from '@core/components/shared/config/configNavSections';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PasswordSecurity from '@app/components/shared/config/configSections/PasswordSecurity';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import Plan from '@app/components/shared/config/configSections/Plan';
} from "@core/components/shared/config/configNavSections";
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
interface CreateSaasConfigNavSectionsOptions {
isDev?: boolean;
isAnonymous?: boolean;
t: TFunction<'translation', undefined>;
t: TFunction<"translation", undefined>;
}
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
const preferencesIndex = sections.findIndex((section) => section.title === "Preferences");
if (preferencesIndex === -1) {
return [
...sections,
{
title: 'Preferences',
title: "Preferences",
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
key: "general",
label: "General",
icon: "settings-rounded",
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
key: "hotkeys",
label: "Keyboard Shortcuts",
icon: "keyboard-rounded",
component: <HotkeysSection />,
},
],
@@ -48,8 +48,8 @@ function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSectio
}
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const hasDeveloper = sections.some(section =>
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
const hasDeveloper = sections.some((section) =>
section.items.some((item) => item.key === "developer" || item.key === "api-keys"),
);
if (hasDeveloper) {
@@ -59,12 +59,12 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
return [
...sections,
{
title: 'Developer',
title: "Developer",
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
@@ -72,13 +72,8 @@ function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[
];
}
function appendBillingSection(
sections: ConfigNavSection[],
t: TFunction<'translation', undefined>,
): ConfigNavSection[] {
const hasPlan = sections.some(section =>
section.items.some(item => item.key === 'plan'),
);
function appendBillingSection(sections: ConfigNavSection[], t: TFunction<"translation", undefined>): ConfigNavSection[] {
const hasPlan = sections.some((section) => section.items.some((item) => item.key === "plan"));
if (hasPlan) {
return sections;
@@ -87,12 +82,12 @@ function appendBillingSection(
return [
...sections,
{
title: 'Billing',
title: "Billing",
items: [
{
key: 'plan',
label: t('config.plan', 'Plan'),
icon: 'credit-card',
key: "plan",
label: t("config.plan", "Plan"),
icon: "credit-card",
component: <Plan />,
},
],
@@ -109,18 +104,18 @@ export function createSaasConfigNavSections(
// Create Account section as the first section with Overview and Passwords & Security
const accountSection: ConfigNavSection = {
title: t('config.account.overview.title', 'Account Settings'),
title: t("config.account.overview.title", "Account Settings"),
items: [
{
key: 'overview',
label: t('config.account.overview.label', 'Overview'),
icon: 'account-circle',
key: "overview",
label: t("config.account.overview.label", "Overview"),
icon: "account-circle",
component: <Overview onLogoutClick={onLogoutClick} />,
},
{
key: 'security',
label: 'Passwords & Security',
icon: 'lock',
key: "security",
label: "Passwords & Security",
icon: "lock",
component: <PasswordSecurity />,
},
],
@@ -129,12 +124,10 @@ export function createSaasConfigNavSections(
let sections = [accountSection, ...baseSections];
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
sections = sections.map(section => ({
sections = sections.map((section) => ({
...section,
items: section.items.map(item =>
item.key === 'general'
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
: item
items: section.items.map((item) =>
item.key === "general" ? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> } : item,
),
}));
@@ -146,7 +139,7 @@ export function createSaasConfigNavSections(
}
if (isDev) {
console.debug('[AppConfigModal] SaaS navigation sections', sections);
console.debug("[AppConfigModal] SaaS navigation sections", sections);
}
return sections;
@@ -1,67 +1,67 @@
// Re-export from core for compatibility
// Override VALID_NAV_KEYS to include saas-specific keys
export const VALID_NAV_KEYS = [
'overview',
'password-security',
'preferences',
'notifications',
'connections',
'general',
'people',
'teams',
'security',
'identity',
'plan',
'payments',
'requests',
'developer',
'api-keys',
'hotkeys',
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
'adminLegal',
'adminPremium',
'adminFeatures',
'adminPlan',
'adminAudit',
'adminUsage',
'adminEndpoints'
"overview",
"password-security",
"preferences",
"notifications",
"connections",
"general",
"people",
"teams",
"security",
"identity",
"plan",
"payments",
"requests",
"developer",
"api-keys",
"hotkeys",
"adminGeneral",
"adminSecurity",
"adminConnections",
"adminPrivacy",
"adminDatabase",
"adminAdvanced",
"adminLegal",
"adminPremium",
"adminFeatures",
"adminPlan",
"adminAudit",
"adminUsage",
"adminEndpoints",
] as const;
// Extend NavKey to include saas-specific keys
export type NavKey =
| 'overview'
| 'password-security'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminPlan'
| 'adminAudit'
| 'adminUsage'
| 'adminEndpoints';
| "overview"
| "password-security"
| "preferences"
| "notifications"
| "connections"
| "general"
| "people"
| "teams"
| "security"
| "identity"
| "plan"
| "payments"
| "requests"
| "developer"
| "api-keys"
| "hotkeys"
| "adminGeneral"
| "adminSecurity"
| "adminConnections"
| "adminPrivacy"
| "adminDatabase"
| "adminAdvanced"
| "adminLegal"
| "adminPremium"
| "adminFeatures"
| "adminPlan"
| "adminAudit"
| "adminUsage"
| "adminEndpoints";
// some of these are not used yet, but appear in figma designs
// some of these are not used yet, but appear in figma designs
@@ -10,5 +10,3 @@ export function formatUTC(iso: string, withTime: boolean): string {
}).format(date);
return withTime ? `${formatted} UTC` : formatted;
}
File diff suppressed because it is too large Load Diff