Add SaaS frontend code (#5879)

# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
This commit is contained in:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useAuth } from '@app/auth/UseSession';
import SaasOnboardingModal from '@app/components/onboarding/SaasOnboardingModal';
const STORAGE_KEY = 'saas_onboarding_seen';
const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
/**
* SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed,
* and show SaaS-specific onboarding on first login.
*/
export default function OnboardingBootstrap() {
const { preferences, updatePreference } = usePreferences();
const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding();
const { user, loading, trialStatus, isPro, refreshTrialStatus } = useAuth();
const [showModal, setShowModal] = useState(false);
const [isPolling, setIsPolling] = useState(false);
const [pollAttempts, setPollAttempts] = useState(0);
// Start polling when user logs in
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === 'true';
if (user && !hasSeenOnboarding && !loading && !isPolling && !showModal) {
console.debug('[Onboarding] Starting poll for trial data');
setIsPolling(true);
setPollAttempts(0);
}
}, [user, loading, isPolling, showModal]);
// Poll for trial data
useEffect(() => {
if (!isPolling) return;
const pollInterval = 500; // Check every 500ms
const timer = setTimeout(async () => {
const newAttempts = pollAttempts + 1;
console.debug('[Onboarding] Polling for trial data, attempt:', newAttempts);
await refreshTrialStatus();
setPollAttempts(newAttempts);
// Check will happen in the next effect
}, pollInterval);
return () => clearTimeout(timer);
}, [isPolling, pollAttempts, refreshTrialStatus]);
// Stop polling when data arrives or timeout
useEffect(() => {
if (!isPolling) return;
const hasData = trialStatus !== undefined && trialStatus !== null;
const hasProStatus = isPro !== null;
const maxAttempts = 10;
if (hasData || pollAttempts >= maxAttempts) {
console.debug('[Onboarding] Trial data ready or timeout, showing modal', {
hasData,
hasProStatus,
attempts: pollAttempts,
trialStatus,
isPro
});
setIsPolling(false);
setShowModal(true);
}
}, [isPolling, trialStatus, isPro, pollAttempts]);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, 'true');
setShowModal(false);
};
// Keep existing logic to disable core onboarding flags
useEffect(() => {
// Ensure tool panel preference is set so tours are never deferred.
if (!preferences.toolPanelModePromptSeen || !preferences.hasSelectedToolPanelMode) {
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
}
// Clear any lingering deferred tour requests.
clearPendingTourRequest();
setStartAfterToolModeSelection(false);
// In SaaS, skip the core intro onboarding entirely.
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
// Also mark completed to avoid follow-up banners/modals.
if (!preferences.hasCompletedOnboarding) {
updatePreference('hasCompletedOnboarding', true);
}
// Also clear any session flag that might mark onboarding as active.
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
}
}, [
preferences.hasSelectedToolPanelMode,
preferences.toolPanelModePromptSeen,
preferences.hasSeenIntroOnboarding,
preferences.hasCompletedOnboarding,
updatePreference,
clearPendingTourRequest,
setStartAfterToolModeSelection,
]);
// Only render modal when it should be shown to avoid running hooks unnecessarily
return showModal ? <SaasOnboardingModal opened={showModal} onClose={handleClose} /> : null;
}
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { TrialExpiredModal } from '@app/components/shared/TrialExpiredModal';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
/**
* Bootstrap component that shows the trial expired modal when a user's trial has ended
* and they haven't added a payment method. Shows once per user per expired trial.
*/
export default function TrialExpiredBootstrap() {
const { user, trialStatus, isPro } = useAuth();
const [showModal, setShowModal] = useState(false);
const [checkoutOpened, setCheckoutOpened] = useState(false);
useEffect(() => {
// Close modal if user logs out or session expires
if (!user) {
if (showModal) {
console.debug('[TrialExpired] User logged out, closing modal');
setShowModal(false);
}
if (checkoutOpened) {
setCheckoutOpened(false);
}
return;
}
// Only check conditions when auth is fully loaded
if (trialStatus === null || isPro === null) {
return;
}
// Build localStorage key unique to this user
const storageKey = `trialExpiredModalShown_${user.id}`;
const hasSeenModal = localStorage.getItem(storageKey) === 'true';
// If user is currently trialing, clear any previous "seen" flag
// This handles the edge case where a user might re-enter a trial
if (trialStatus.isTrialing) {
if (hasSeenModal) {
console.debug('[TrialExpired] User is trialing, clearing seen flag');
localStorage.removeItem(storageKey);
}
return;
}
// Check if all conditions are met to show the modal
const isExpired =
trialStatus.status === 'incomplete_expired' || trialStatus.status === 'canceled';
const hasNoPayment = !trialStatus.hasPaymentMethod && !trialStatus.hasScheduledSub;
const wasDowngraded = !isPro;
const trialEndedRecently = trialStatus.daysRemaining === 0;
const shouldShowModal =
isExpired && hasNoPayment && wasDowngraded && trialEndedRecently && !hasSeenModal;
if (shouldShowModal) {
console.debug('[TrialExpired] Showing trial expired modal', {
status: trialStatus.status,
daysRemaining: trialStatus.daysRemaining,
hasPaymentMethod: trialStatus.hasPaymentMethod,
hasScheduledSub: trialStatus.hasScheduledSub,
isPro,
});
setShowModal(true);
}
}, [user, trialStatus, isPro, showModal, checkoutOpened]);
const handleClose = () => {
if (user) {
const storageKey = `trialExpiredModalShown_${user.id}`;
localStorage.setItem(storageKey, 'true');
console.debug('[TrialExpired] Modal dismissed, marking as seen');
}
setShowModal(false);
};
const handleSubscribe = () => {
console.debug('[TrialExpired] User clicked Subscribe to Pro');
setCheckoutOpened(true);
};
const handleCheckoutSuccess = () => {
console.debug('[TrialExpired] Subscription successful, refreshing page');
// Close modal and refresh to update subscription status
handleClose();
window.location.reload();
};
const handleCheckoutClose = () => {
console.debug('[TrialExpired] Checkout closed');
setCheckoutOpened(false);
};
return (
<>
<TrialExpiredModal
opened={showModal && !checkoutOpened}
onClose={handleClose}
onSubscribe={handleSubscribe}
/>
{user && (
<StripeCheckout
opened={checkoutOpened}
onClose={handleCheckoutClose}
purchaseType="subscription"
planId="pro"
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('[TrialExpired] Checkout error:', error)}
isTrialConversion={false} // Trial already ended, so this is not a conversion
/>
)}
</>
);
}
@@ -0,0 +1,90 @@
.guest-banner {
position: fixed;
top: 1rem;
right: 4.5rem;
z-index: 1000;
background: var(--modal-content-bg, #111418);
border: 1px solid var(--api-keys-card-border, rgba(255,255,255,0.08));
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.35);
padding: 12px 16px;
max-width: 30rem;
width: 30rem;
color: var(--mantine-color-text);
}
.guest-banner-content {
display: flex;
align-items: center;
gap: 12px;
}
.guest-banner-text {
flex: 1;
min-width: 0;
}
.guest-banner-title {
font-size: 13px;
font-weight: 600;
color: var(--mantine-color-text);
margin-bottom: 4px;
line-height: 1.3;
}
.guest-banner-message {
font-size: 12px;
color: var(--mantine-color-dimmed);
line-height: 1.4;
margin-bottom: 8px;
}
.guest-banner-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.guest-banner-dismiss {
padding: 6px;
border-radius: 8px;
background: transparent;
color: var(--mantine-color-dimmed);
border: none;
cursor: pointer;
}
.guest-banner-dismiss:hover {
background: var(--mantine-color-gray-1, rgba(255,255,255,0.05));
}
.guest-banner-signup {
white-space: nowrap;
padding: 6px 12px;
border-radius: 8px;
background: var(--blue-6, #2563eb);
color: #fff;
border: none;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.guest-banner-signup:hover {
background: var(--blue-7, #1d4ed8);
}
.guest-banner-icon {
width: 16px;
height: 16px;
}
.guest-banner-signup-icon {
width: 14px;
height: 14px;
}
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CloseIcon from '@mui/icons-material/Close'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import { useAuth } from '@app/auth/UseSession'
import { isUserAnonymous } from '@app/auth/supabase'
import { withBasePath } from '@app/constants/app'
import '@app/components/auth/GuestUserBanner.css'
interface GuestUserBannerProps {
className?: string
}
// Ensure the toast only appears once per full page load, not on re-hydration
let hasShownThisLoad = false
/**
* Guest user toast encouraging account creation.
* Appears 2s after load, top-right of the viewport, offset by right rail.
*/
export function GuestUserBanner({ className = '' }: GuestUserBannerProps) {
const { t } = useTranslation()
const { session } = useAuth()
const [isDismissed, setIsDismissed] = useState(false)
const [visible, setVisible] = useState(false)
const isAnon = Boolean(session?.user && isUserAnonymous(session.user))
useEffect(() => {
if (!isAnon || hasShownThisLoad) return
const timer = setTimeout(() => {
setVisible(true)
hasShownThisLoad = true
}, 2000)
return () => clearTimeout(timer)
}, [isAnon])
if (!isAnon || isDismissed || !visible) {
return null
}
const handleSignUp = () => {
window.location.href = withBasePath('/signup')
}
const handleDismiss = () => {
setIsDismissed(true)
}
return (
<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-message">
{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')}
className="guest-banner-dismiss"
>
<CloseIcon className="guest-banner-icon" />
</button>
<button
onClick={handleSignUp}
className="guest-banner-signup"
>
<PersonAddIcon className="guest-banner-signup-icon" />
{t('guestBanner.signUp', 'Sign Up Free')}
</button>
</div>
</div>
</div>
)
}
export default GuestUserBanner
@@ -0,0 +1,44 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAutoAnonymousAuth } from '@app/hooks/useAutoAnonymousAuth'
interface RequireAuthProps {
fallbackPath?: string
}
export function RequireAuth({ fallbackPath = '/login' }: RequireAuthProps) {
const { session, loading } = useAuth()
const location = useLocation()
const { isAutoAuthenticating } = useAutoAnonymousAuth()
// Safe development-only auth bypass
const isLocalhost = typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
const devBypassEnabled = Boolean(import.meta.env.DEV && isLocalhost && import.meta.env.VITE_DEV_BYPASS_AUTH === 'true')
if (devBypassEnabled) {
console.warn('[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost')
return <Outlet />
}
// Wait for both auth bootstrap and auto-anon to finish
if (loading || isAutoAuthenticating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin h-6 w-6 mx-auto mb-3 border-2 border-gray-300 border-t-transparent rounded-full" />
<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 }} />
}
// Render protected routes
return <Outlet />
}
export default RequireAuth
@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react';
import Userback from '@userback/widget';
import { useAuth } from '@app/auth/UseSession';
interface UserbackWidgetProps {
token: string;
}
interface UserbackInstance {
destroy: () => void;
}
export default function UserbackWidget({ token }: UserbackWidgetProps) {
const { user } = useAuth();
const userbackRef = useRef<UserbackInstance | null>(null);
const initializingRef = useRef(false);
useEffect(() => {
if (!user || initializingRef.current) return;
initializingRef.current = true;
const initializeUserback = async () => {
try {
// Prepare user data options
const userInfo: { name?: string; email?: string } = {};
if (user.user_metadata?.full_name) userInfo.name = user.user_metadata.full_name;
if (user.email) userInfo.email = user.email;
const options = {
user_data: {
id: user.id,
info: userInfo
}
};
// Initialize Userback
userbackRef.current = await Userback(token, options);
}
finally {
initializingRef.current = false;
}
};
initializeUserback();
// Cleanup function
return () => {
if (userbackRef.current && typeof userbackRef.current.destroy === 'function') {
userbackRef.current.destroy();
}
initializingRef.current = false;
};
}, [user, token]);
return null; // This component doesn't render anything visible
}
@@ -0,0 +1,6 @@
import UserbackWidget from '@app/components/feedback/UserbackWidget';
export function HomePageExtensions() {
const userbackToken = import.meta.env.VITE_USERBACK_TOKEN;
return userbackToken ? <UserbackWidget token={userbackToken} /> : null;
}
@@ -0,0 +1,7 @@
/**
* SaaS stub — core tour system is suppressed in SaaS.
* SaaS uses SaasOnboardingModal instead.
*/
export default function OnboardingTour() {
return null;
}
@@ -0,0 +1,131 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { renderButtons } from '@app/components/onboarding/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface SaasOnboardingModalProps {
opened: boolean;
onClose: () => void;
}
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
const { t } = useTranslation();
const flow = useSaasOnboardingState(props);
if (!flow) {
return null;
}
const {
currentStep,
totalSteps,
currentSlide,
slideDefinition,
flowState,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</div>
);
}
return (
<div className={styles.heroLogoCircle}>
{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' }} />}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={props.onClose}
closeOnClickOutside={false}
centered
size="lg"
radius="lg"
withCloseButton={false}
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',
},
}}
>
<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}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
flowState,
onAction: handleButtonAction,
t,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,95 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { TFunction } from 'i18next';
import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
flowState: FlowState;
onAction: (action: ButtonAction) => void;
t: TFunction;
}
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
return (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
{label}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
@@ -0,0 +1,40 @@
import { TrialStatus } from '@app/auth/UseSession';
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
export interface FlowConfig {
type: 'saas-trial' | 'saas-paid';
ids: SlideId[];
}
/**
* Resolves the appropriate onboarding flow based on user's subscription status.
*
* @param trialStatus - User's trial information from Supabase
* @param _isPro - Whether user has Pro subscription
* @returns FlowConfig with the appropriate slide sequence
*/
export function resolveSaasFlow(
trialStatus: TrialStatus | null,
_isPro: boolean | null
): FlowConfig {
// Show free trial card if:
// 1. User has active trial (isTrialing = true)
// 2. Trial has not expired (daysRemaining > 0)
// 3. User is not paid Pro (or Pro is from trial)
const hasActiveTrial =
trialStatus?.isTrialing === true &&
trialStatus.daysRemaining > 0;
if (hasActiveTrial) {
return {
type: 'saas-trial',
ids: FLOW_SEQUENCES.saasTrialUser,
};
}
// For paid users, expired trials, or no trial info
return {
type: 'saas-paid',
ids: FLOW_SEQUENCES.saasPaidUser,
};
}
@@ -0,0 +1,134 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import FreeTrialSlide from '@app/components/onboarding/slides/FreeTrialSlide';
import { SlideConfig } from '@app/types/types';
import { TrialStatus } from '@app/auth/UseSession';
export type SlideId = 'welcome' | 'free-trial' | 'desktop-install';
export type HeroType = 'rocket' | 'dual-icon' | 'diamond';
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'download-selected';
export type FlowState = Record<string, never>;
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
trialStatus?: TrialStatus | null;
}
export interface HeroDefinition {
type: HeroType;
}
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
id: 'welcome',
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'free-trial': {
id: 'free-trial',
createSlide: ({ trialStatus }) => {
if (!trialStatus) {
throw new Error('Trial status is required for free-trial slide');
}
return FreeTrialSlide({ trialStatus });
},
hero: { type: 'diamond' },
buttons: [
{
key: 'trial-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'trial-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
},
],
},
};
export const FLOW_SEQUENCES = {
saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
};
@@ -0,0 +1,81 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { TrialStatus } from '@app/auth/UseSession';
interface FreeTrialSlideProps {
trialStatus: TrialStatus;
}
function FreeTrialSlideTitle() {
const { t } = useTranslation();
return (
<span>
{t('onboarding.freeTrial.title', 'Your 30-Day Pro Trial')}
</span>
);
}
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
const { t } = useTranslation();
// Format the trial end date
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Determine which message to show based on payment method
const afterTrialMessage = trialStatus.hasScheduledSub
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
: trialStatus.hasPaymentMethod
? t('onboarding.freeTrial.afterTrialWithPayment', 'Your Pro subscription will start automatically when the trial ends.')
: t('onboarding.freeTrial.afterTrialWithoutPayment', 'After your trial ends, you\'ll continue with our free tier. Add a payment method to keep Pro access.');
// Pluralize days remaining
const daysText = trialStatus.daysRemaining === 1
? t('onboarding.freeTrial.daysRemainingSingular', '{{days}} day remaining', { days: trialStatus.daysRemaining })
: t('onboarding.freeTrial.daysRemaining', '{{days}} days remaining', { days: trialStatus.daysRemaining });
return (
<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.'
)}
</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>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{afterTrialMessage}
</p>
</div>
);
};
export default function FreeTrialSlide({ trialStatus }: FreeTrialSlideProps): SlideConfig {
return {
key: 'free-trial',
title: <FreeTrialSlideTitle />,
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
background: {
gradientStops: ['#10B981', '#06B6D4'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { useOs } from '@app/hooks/useOs';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/saasOnboardingFlowConfig';
import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseSaasOnboardingStateResult {
currentStep: number;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
flowState: FlowState;
handleButtonAction: (action: ButtonAction) => void;
}
interface UseSaasOnboardingStateProps {
opened: boolean;
onClose: () => void;
}
export function useSaasOnboardingState({
opened,
onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth();
const osType = useOs();
const selectedDownloadUrlRef = useRef<string>('');
const [currentStep, setCurrentStep] = useState<number>(0);
// Reset state when modal closes
useEffect(() => {
if (!opened) {
setCurrentStep(0);
}
}, [opened]);
// Determine OS details for desktop download
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
];
return options.filter(opt => opt.url);
}, []);
// Store selected download URL
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
// Resolve flow based on trial status
const resolvedFlow = useMemo(
() => resolveSaasFlow(trialStatus, isPro),
[trialStatus, isPro]
);
const flowSlideIds = resolvedFlow.ids;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
// Ensure current step is within bounds
useEffect(() => {
if (currentStep >= flowSlideIds.length) {
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
}
}, [flowSlideIds.length, currentStep]);
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
// Create slide with appropriate params - must be called before any early returns
const currentSlide = useMemo(() => {
if (!slideDefinition) return null;
return slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
trialStatus: trialStatus ?? undefined,
});
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
// Navigation functions
const goNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
}, [maxIndex]);
const goPrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
// Handle button actions
const handleButtonAction = useCallback(
(action: ButtonAction) => {
switch (action) {
case 'next':
// If on last slide, close modal
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
case 'prev':
goPrev();
return;
case 'close':
onClose();
return;
case 'download-selected': {
// Open download URL in new tab
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
}
default:
console.warn(`Unhandled button action: ${action}`);
return;
}
},
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
);
const flowState: FlowState = {};
// Early return after all hooks have been called
if (!slideDefinition || !currentSlide || loading) {
return null;
}
return {
currentStep,
totalSteps,
slideDefinition,
currentSlide,
flowState,
handleButtonAction,
};
}
@@ -0,0 +1,238 @@
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Modal, Button, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useAuth } from '@app/auth/UseSession';
import { isUserAnonymous } from '@app/auth/supabase';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createSaasConfigNavSections } from '@app/components/shared/config/saasConfigNavSections';
import { NavKey } from '@app/components/shared/config/types';
import { withBasePath } from '@app/constants/app';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
const { signOut, user, creditBalance, refreshCredits } = useAuth();
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>('overview');
const [notice, setNotice] = useState<string | null>(null);
// Check if user can access billing features (non-anonymous users only)
const isAnonymous = user ? isUserAnonymous(user) : false;
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
// Listen for notice updates (e.g., "Not enough credits..." next to Plan title)
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey; notice?: string } | undefined;
if (detail?.notice && (detail?.key ? detail.key === 'plan' : true)) {
setNotice(detail.notice);
}
};
window.addEventListener('appConfig:notice', handler as EventListener);
return () => window.removeEventListener('appConfig:notice', handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Opening Plan section. Current creditBalance:', creditBalance);
(async () => {
try {
await refreshCredits();
} catch (e) {
console.warn('[AppConfigModal] Failed to refresh credits on Plan open:', e);
}
})();
}, [opened, active]);
useEffect(() => {
if (!opened) return;
if (active !== 'plan') return;
console.log('[AppConfigModal] Credit balance updated while viewing Plan:', creditBalance);
}, [opened, active, creditBalance]);
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
const isDev = process.env.NODE_ENV === 'development';
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
// Left navigation structure and icons
const configNavSections = useMemo(
() =>
createSaasConfigNavSections(Overview, openLogoutConfirm, {
isDev,
isAnonymous,
t,
}),
[openLogoutConfirm, isDev, isAnonymous, t],
);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
return (
<>
<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>
);
})}
</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}
</div>
</div>
</div>
</div>
</Modal>
{/* Confirm logout modal */}
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Sign out"
centered
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
<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
color="red"
onClick={async () => {
try {
await signOut();
} finally {
setConfirmOpen(false);
onClose();
window.location.href = withBasePath('/login');
}
}}
>
Sign out
</Button>
</div>
</div>
</Modal>
</>
);
};
export default AppConfigModal;
@@ -0,0 +1,179 @@
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
type InfoBannerTone = 'info' | 'warning';
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: 'var(--mantine-color-blue-0)',
border: 'var(--mantine-color-blue-2)',
text: 'var(--mantine-color-blue-9)',
icon: 'var(--mantine-color-blue-6)',
buttonColor: 'blue',
},
warning: {
background: 'var(--mantine-color-orange-0)',
border: 'var(--mantine-color-orange-3)',
text: 'var(--mantine-color-orange-9)',
icon: 'var(--mantine-color-orange-7)',
buttonColor: 'orange',
},
};
interface InfoBannerProps {
icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images)
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
buttonTextColor?: string; // SaaS-specific for dark theme buttons
minHeight?: number | string;
closeIconColor?: string; // SaaS-specific for dark theme
}
/**
* SaaS-specific info banner with enhanced theming support
* Supports ReactNode icons (e.g., logo images) and custom button text colors
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
buttonTextColor,
minHeight = 56,
closeIconColor,
}) => {
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const handleDismiss = () => {
onDismiss?.();
};
return (
<Paper
p="sm"
radius={0}
style={{
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
}}
>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{icon && (
typeof icon === 'string' ? (
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<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}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
styles={
buttonTextColor
? {
label: {
color: buttonTextColor,
},
}
: buttonVariant !== 'white' && buttonVariant !== 'filled'
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
}
>
{buttonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { supabase } from '@app/auth/supabase';
import { Button } from '@mantine/core';
import { usePlans } from '@app/hooks/usePlans';
interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
export function ManageBillingButton({
returnUrl = typeof window !== 'undefined' ? window.location.href : '/',
children = 'Manage billing',
trialStatus,
}: {
returnUrl?: string;
children?: React.ReactNode;
trialStatus?: TrialStatus;
}) {
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const { data } = usePlans();
// Hide for free plan users
if (!data || data.currentPlan.id === 'free') {
return null;
}
// Hide for trial users who haven't scheduled a subscription yet
if (trialStatus?.isTrialing && !trialStatus.hasScheduledSub) {
return null;
}
const onClick = async () => {
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke('manage-billing', {
body: {
name: 'Functions',
return_url: returnUrl},
})
if (error) throw error;
if (!data || 'error' in data) throw new Error((data as any)?.error ?? 'No portal URL');
window.location.href = (data as any).url;
} catch (e: any) {
setErr(e.message ?? 'Could not open billing portal');
} finally {
setLoading(false);
}
};
return (
<div>
<Button onClick={onClick} disabled={loading} className="px-4 py-2 rounded bg-black text-white">
{loading ? 'Opening…' : children}
</Button>
{err && <div className="mt-2 text-red-600">{err}</div>}
</div>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
}
/**
* SaaS override of the OSS PrivateContent wrapper.
* Adds both the PostHog no-capture class and the Userback opt-out class
* while keeping the same API and layout behavior (display: contents).
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const baseClass = 'ph-no-capture userback-block';
const combinedClassName = className ? `${baseClass} ${className}` : baseClass;
const combinedStyle = {
display: 'contents' as const,
...style,
};
return (
<span className={combinedClassName} style={combinedStyle} {...props}>
{children}
</span>
);
};
@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY);
export type PurchaseType = 'subscription' | 'credits';
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
export type PlanID = 'pro' | null;
interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
// Saas-specific props
planId?: PlanID;
purchaseType?: PurchaseType;
creditsPack?: CreditsPack;
planName?: string;
planPrice?: number;
currency?: string;
isTrialConversion?: boolean;
// Proprietary-specific props (for compatibility)
planGroup?: any;
minimumSeats?: number;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
// Common props
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionParams?: {
purchaseType: PurchaseType;
planId: PlanID;
creditsPack: CreditsPack;
};
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planId,
purchaseType,
creditsPack,
planName,
isTrialConversion,
onSuccess,
onError
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
const createCheckoutSession = async () => {
try {
setState({ status: 'loading' });
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
purchase_type: purchaseType,
ui_mode: 'embedded',
plan: planId,
credits_pack: creditsPack,
callback_base_url: window.location.origin,
trial_conversion: isTrialConversion || false
}
});
if (error) {
throw new Error(error.message || 'Failed to create checkout session');
}
if (!data) {
throw new Error('No data received from server');
}
const jsonData = typeof data === 'string' ? JSON.parse(data) : data;
if (!jsonData?.clientSecret) {
throw new Error('No client secret received from server');
}
setState({
status: 'ready',
clientSecret: jsonData.clientSecret,
sessionParams: {
purchaseType: purchaseType!,
planId: planId!,
creditsPack: creditsPack!
}
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage
});
onError?.(errorMessage);
}
};
const handlePaymentComplete = () => {
setState({ status: 'success' });
// Call success callback immediately - parent will handle timing
onSuccess?.('');
// Note: Parent (Plan.tsx) now handles the delay and modal closing
};
const handleClose = () => {
// Reset state to idle to clean up the session
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
onClose();
};
// Initialize checkout when modal opens or parameters change
useEffect(() => {
if (opened) {
// Check if we need a new session (first time or parameters changed)
const needsNewSession =
state.status === 'idle' ||
!state.sessionParams ||
state.sessionParams.purchaseType !== purchaseType ||
state.sessionParams.planId !== planId ||
state.sessionParams.creditsPack !== creditsPack;
if (needsNewSession) {
console.log('Creating new checkout session:', { purchaseType, planId, creditsPack });
createCheckoutSession();
}
} else if (!opened) {
// Clean up state when modal closes
setState({ status: 'idle', clientSecret: undefined, error: undefined, sessionParams: undefined });
}
}, [opened, purchaseType, planId, creditsPack]);
const renderContent = () => {
switch (state.status) {
case 'loading':
return (
<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...')}
</Text>
</div>
);
case 'ready':
if (!state.clientSecret) return null;
return (
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
case 'success':
return (
<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.')}
</Text>
<Text size="xs" c="dimmed">
{t('payment.autoClose', 'This window will close automatically...')}
</Text>
</Stack>
</Alert>
);
case 'error':
return (
<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')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<div>
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
</Text>
</div>
}
size="xl"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
export { StripeCheckout };
@@ -0,0 +1,178 @@
import { Modal, Stack, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface TrialExpiredModalProps {
opened: boolean;
onClose: () => void;
onSubscribe: () => void;
}
export function TrialExpiredModal({ opened, onClose, onSubscribe }: TrialExpiredModalProps) {
const { t } = useTranslation();
// Use CSS variables for theme colors
const amberColor = getComputedStyle(document.documentElement).getPropertyValue('--color-amber-500').trim() || '#f59e0b';
const redColor = getComputedStyle(document.documentElement).getPropertyValue('--color-red-500').trim() || '#ef4444';
const gradientStops: [string, string] = [amberColor, redColor];
const circles = [
{
position: 'bottom-left' as const,
size: 270, // 16.875rem
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24, // 1.5rem
duration: 4.5,
offsetX: 18, // 1.125rem
offsetY: 14, // 0.875rem
},
{
position: 'top-right' as const,
size: 300, // 18.75rem
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.9,
amplitude: 28, // 1.75rem
duration: 4.5,
delay: 0.5,
offsetX: 24, // 1.5rem
offsetY: 18, // 1.125rem
},
];
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing by clicking outside or ESC
withCloseButton={false}
closeOnClickOutside={false}
closeOnEscape={false}
centered
size="lg"
radius="lg"
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',
},
}}
>
<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"
/>
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />
</div>
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
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.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.'
)}
</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.')}
</div>
</div>
<div className={styles.buttonContainer}>
<style>{`
@media (max-width: 30rem) {
.trial-button-container {
justify-content: center !important;
}
.trial-modal-button {
flex: 1 1 100% !important;
}
}
.trial-modal-button-primary:hover {
background: linear-gradient(135deg, var(--color-amber-600), var(--color-red-600)) !important;
}
`}</style>
<div
className="trial-button-container"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
justifyContent: 'space-between',
}}
>
<Button
onClick={onClose}
variant="default"
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'
}}
>
{t('plan.trial.continueWithFree', 'Continue with Free')}
</Button>
<Button
onClick={onSubscribe}
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',
fontWeight: 600,
padding: '0.75rem 1.5rem',
height: 'auto',
border: 'none',
minWidth: '10.625rem',
flex: '0 1 auto',
}}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
</Button>
</div>
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useState, useCallback } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import StripeCheckout from '@app/components/shared/StripeCheckoutSaas';
import { BASE_PATH } from '@app/constants/app';
const SESSION_STORAGE_KEY = 'trialBannerDismissed';
export function TrialStatusBanner() {
const { setBanner } = useBanner();
const { t } = useTranslation();
const { trialStatus } = useAuth();
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(SESSION_STORAGE_KEY) === 'true';
});
const [checkoutOpen, setCheckoutOpen] = useState(false);
// Only show banner during ACTIVE trial (not after expiration - modal handles that)
// Don't show if payment method already added (user has scheduled subscription)
const shouldShowBanner =
trialStatus &&
trialStatus.isTrialing && // Only show during active trial
trialStatus.daysRemaining > 0 && // Trial hasn't expired yet
!trialStatus.hasPaymentMethod &&
!trialStatus.hasScheduledSub &&
!dismissed;
if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) {
console.log('Subscription scheduled - hiding trial banner');
}
const handleOpenCheckout = useCallback(() => {
setCheckoutOpen(true);
}, []);
const handleDismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
}, []);
useEffect(() => {
if (!shouldShowBanner) {
setBanner(null);
return;
}
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString('en-GB', {
month: 'short',
day: 'numeric'
});
const message = t(
'plan.trial.message',
`Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? 's' : ''} (${trialEndDate}). Subscribe to continue Pro access.`,
{ days: trialStatus.daysRemaining, date: trialEndDate }
);
const logoIcon = (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling PDF"
style={{
width: '1.5rem',
height: '1.5rem',
objectFit: 'contain'
}}
/>
);
setBanner(
<InfoBanner
icon={logoIcon}
tone="info"
message={message}
buttonText={t('plan.trial.subscribe', 'Subscribe to Pro')}
buttonIcon="credit-card-rounded"
onButtonClick={handleOpenCheckout}
onDismiss={handleDismiss}
dismissible={true}
show={true}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
buttonColor="gray"
buttonVariant="white"
buttonTextColor="var(--mantine-color-dark-9)"
closeIconColor="rgba(255, 255, 255, 0.7)"
/>
);
return () => {
setBanner(null);
};
}, [shouldShowBanner, trialStatus, setBanner, t, handleOpenCheckout, handleDismiss]);
const handleCheckoutSuccess = () => {
// Refresh to hide banner and show updated plan
window.location.reload();
};
return (
<>
{trialStatus && (
<StripeCheckout
opened={checkoutOpen}
onClose={() => setCheckoutOpen(false)}
purchaseType="subscription"
planId="pro"
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error('Checkout error:', error)}
isTrialConversion={true}
/>
)}
</>
);
}
@@ -0,0 +1,268 @@
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,
showLegend = true,
className = '',
tooltipPosition = 'top',
loading = false,
animate = true,
animationDurationMs = 900,
ariaLabel,
}: StackedBarChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const hasAnimatedRef = useRef(false);
const reactId = useId();
const clipId = useMemo(() => `clip-${reactId}`, [reactId]);
// Memoize theme detection to avoid recalculation
const theme = useMemo(() => detectTheme(), []);
const themeVars = useMemo(() => getChartThemeVars(theme.isDark), [theme.isDark]);
// 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 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';
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
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
}));
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);
const x = createScale([0, totalCapacity], [0, width]);
let cursor = 0;
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);
// Define a clipPath that will reveal the used portion from left to right
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);
// Group to hold the used segments and apply clip-path
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) => {
if (fraction.value <= 0) return;
const segWidth = x(fraction.value);
const xPos = cursor;
cursor += segWidth;
const isFirst = index === 0;
const isLast = index === data.length - 1; // Last segment regardless of remaining
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);
} 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
});
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
});
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);
}
});
// 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) => {
const tooltipData: TooltipData = { fractions: data, isDark: theme.isDark };
const html = generateTooltipHTML(tooltipData);
setTooltipContent(html);
const tooltip = tooltipRef.current;
if (tooltip) tooltip.style.opacity = '1';
positionTooltip(event as unknown as MouseEvent);
})
.on('mousemove', (event: MouseEvent) => positionTooltip(event as unknown as MouseEvent))
.on('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 revealTo = x(totalUsed);
if (animate && !hasAnimatedRef.current) {
clipRect.transition().duration(animationDurationMs).attr('width', revealTo);
hasAnimatedRef.current = true;
} else {
clipRect.attr('width', revealTo);
}
}
return () => { container.innerHTML = ''; };
}, [fractions, width, height, tooltipPosition, loading, animate, animationDurationMs, clipId, themeVars, setTooltipContent, hideTooltip, positionTooltip]);
return (
<div className={className}>
<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
}}
/>
{loading && (
<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
}}
/>
<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}`
}}
/>
<Text size="sm">Remaining</Text>
</Group>
</Group>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import React from 'react';
import { TooltipData } from '@app/types/charts';
interface StackedBarTooltipProps {
data: TooltipData;
}
export default function StackedBarTooltip({ data }: StackedBarTooltipProps) {
const { fractions } = data;
return (
<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>
<span>
<strong>{f.name}</strong> {f.numeratorLabel}: {f.numerator} · {f.denominatorLabel}: {f.denominator - f.numerator}
</span>
</div>
))}
</div>
);
}
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 => `
<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('')}
</div>`;
}
@@ -0,0 +1,174 @@
/**
* Reusable D3 utility functions for chart creation
*/
import * as d3 from 'd3';
export interface ChartDimensions {
width: number;
height: number;
margin?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
}
export interface AnimationConfig {
duration: number;
easing?: (t: number) => number;
}
/**
* Creates a basic SVG element with proper attributes
* @param container The container element
* @param dimensions Chart dimensions
* @param className Optional CSS class name
* @returns The created SVG selection
*/
export function createSVG(
container: HTMLElement,
dimensions: ChartDimensions,
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 || '');
return svg;
}
/**
* Creates a clip path for revealing content with animation
* @param svg The SVG selection
* @param clipId Unique ID for the clip path
* @param dimensions Chart dimensions
* @returns The clip rect selection
*/
export function createClipPath(
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
clipId: string,
dimensions: ChartDimensions
): d3.Selection<SVGRectElement, unknown, null, undefined> {
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);
return clipRect;
}
/**
* Animates a clip path to reveal content
* @param clipRect The clip rect selection
* @param targetWidth The target width to animate to
* @param config Animation configuration
*/
export function animateClipReveal(
clipRect: d3.Selection<SVGRectElement, unknown, null, undefined>,
targetWidth: number,
config: AnimationConfig
): void {
clipRect
.transition()
.duration(config.duration)
.ease(config.easing || d3.easeCubicInOut)
.attr('width', targetWidth);
}
/**
* Creates a rounded rectangle path for D3
* @param x X position
* @param y Y position
* @param width Width
* @param height Height
* @param radius Corner radius
* @param corners Which corners to round (default: all)
* @returns SVG path string
*/
export function createRoundedRectPath(
x: number,
y: number,
width: number,
height: number,
radius: number,
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 '';
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';
}
/**
* Creates a reusable scale factory
* @param domain Domain values
* @param range Range values
* @returns D3 scale function
*/
export function createScale(domain: [number, number], range: [number, number]) {
return d3.scaleLinear().domain(domain).range(range);
}
/**
* Debounces a function call
* @param func The function to debounce
* @param wait The wait time in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
@@ -0,0 +1,63 @@
/**
* Utility functions for theme detection and styling in D3 charts
*/
export interface ThemeInfo {
isDark: boolean;
schemeAttr: string | null | undefined;
prefersDark: boolean;
}
/**
* Detects the current theme from various sources
* @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;
return {
isDark,
schemeAttr,
prefersDark
};
}
/**
* Gets CSS custom properties for chart styling
* @param isDark Whether the theme is dark
* @returns Object with CSS custom property values
*/
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)'
};
}
/**
* Applies consistent tooltip styling
* @param tooltipElement The tooltip DOM element
* @param isDark Whether the theme is dark
*/
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';
}
@@ -0,0 +1,74 @@
/**
* Utility functions for tooltip positioning and management in D3 charts
*/
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipPositioner {
positionTooltip: (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => void;
hideTooltip: (tooltip: HTMLElement) => void;
}
/**
* Creates a tooltip positioner for the specified position
* @param position The tooltip position preference
* @returns TooltipPositioner object with positioning functions
*/
export function createTooltipPositioner(position: TooltipPosition): TooltipPositioner {
const positionTooltip = (event: MouseEvent, tooltip: HTMLElement, container: HTMLElement) => {
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':
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':
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':
tooltip.style.left = `${Math.max(10, offsetX - tooltipWidth - gap)}px`;
tooltip.style.top = `${offsetY - tooltipHeight / 2}px`;
break;
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';
};
return { positionTooltip, hideTooltip };
}
/**
* Creates a reusable tooltip element with consistent styling
* @param container The container element to append the tooltip to
* @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';
container.appendChild(tooltip);
return tooltip;
}
@@ -0,0 +1,187 @@
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;
opened: boolean;
onClose: () => void;
onCropComplete: (croppedBlob: Blob) => void;
}
export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
file,
opened,
onClose,
onCropComplete,
}) => {
const { t } = useTranslation();
// State management
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load image when file changes
useEffect(() => {
if (!file) {
setImageSrc(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageSrc(reader.result as string);
setError(null);
};
reader.onerror = () => {
setError(t('config.account.profilePicture.cropper.invalidImage', 'Invalid image file. Please select a valid PNG, JPG, or WebP file.'));
};
reader.readAsDataURL(file);
// Cleanup
return () => {
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
}
};
}, [file, t]);
// Reset state when modal closes
useEffect(() => {
if (!opened) {
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
setProcessing(false);
setError(null);
}
}, [opened]);
// Called when crop area changes
const onCropChange = useCallback((newCrop: { x: number; y: number }) => {
setCrop(newCrop);
}, []);
// Called when zoom changes
const onZoomChange = useCallback((newZoom: number) => {
setZoom(newZoom);
}, []);
// Called when crop is complete (stores the crop area in pixels)
const onCropCompleteCallback = useCallback(
(_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
// Process and save the cropped image
const handleSave = async () => {
if (!imageSrc || !croppedAreaPixels) {
return;
}
setProcessing(true);
setError(null);
try {
// Crop the image
const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels);
// Validate size (2MB limit)
const maxSize = 2 * 1024 * 1024; // 2MB in bytes
if (croppedBlob.size > maxSize) {
setError(
t(
'config.account.profilePicture.cropper.sizeErrorAfterCrop',
'Cropped image is too large. Please zoom out or crop a smaller area.'
)
);
setProcessing(false);
return;
}
// Call parent callback with cropped blob
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.'
)
);
} finally {
setProcessing(false);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t('config.account.profilePicture.cropper.title', 'Crop Profile Picture')}
size="lg"
centered
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{/* Cropper area */}
<Box style={{ position: 'relative', width: '100%', height: 400 }}>
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={onCropChange}
onZoomChange={onZoomChange}
onCropComplete={onCropCompleteCallback}
/>
)}
</Box>
{/* Zoom slider */}
<Stack gap={4}>
<Text size="sm" fw={500}>
{t('config.account.profilePicture.cropper.zoom', 'Zoom')}
</Text>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
onChange={setZoom}
disabled={processing}
/>
</Stack>
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<Button variant="subtle" onClick={onClose} disabled={processing}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={handleSave} loading={processing}>
{processing
? t('config.account.profilePicture.cropper.processing', 'Processing crop...')
: t('config.account.profilePicture.cropper.save', 'Save Cropped Image')}
</Button>
</div>
</Stack>
</Modal>
);
};
@@ -0,0 +1,114 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
export default function ApiKeys() {
const [copied, setCopied] = useState<string | null>(null);
const [showRefreshModal, setShowRefreshModal] = useState(false);
const { t } = useTranslation();
const { user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const { data: credits, isLoading: creditsLoading } = useCredits();
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch, hasAttempted } = useApiKey();
const copy = async (text: string, tag: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
} catch (e) {
// noop you can surface a notification here
console.error(e);
}
};
const refreshKeys = async () => {
try {
await refresh();
} finally {
setShowRefreshModal(false);
}
};
const goToAccount = () => {
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'overview' } }));
};
const showUsage = Boolean(credits);
return (
<Stack gap={20} p={0}>
{showUsage && (
<UsageSection
apiUsage={credits!}
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
overlayMessage={t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
loading={creditsLoading}
/>
)}
{!isAnonymous && apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
{t('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)" }}>
<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 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.')}
</Text>
<Button size="sm" onClick={goToAccount}>
{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>
</>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)
)}
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}
@@ -0,0 +1,515 @@
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;
}
const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const { t } = useTranslation();
const { user, refreshSession, signOut, profilePictureUrl, profilePictureMetadata, refreshProfilePicture, refreshProfilePictureMetadata } = useAuth();
const PROFILE_BUCKET = 'profile-pictures';
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);
const [profileUploading, setProfileUploading] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const [cropperFile, setCropperFile] = useState<File | null>(null);
const [cropperOpen, setCropperOpen] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [confirmEmail, setConfirmEmail] = useState('');
const isAnonymous = Boolean(user && isUserAnonymous(user));
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 handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
return;
}
if (file.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
return;
}
// Open cropper instead of uploading directly
setProfileError(null);
setCropperFile(file);
setCropperOpen(true);
};
const handleCropComplete = async (croppedBlob: Blob) => {
if (!user || !profilePath) {
return;
}
// Validate cropped size (2MB limit)
if (croppedBlob.size > 2 * 1024 * 1024) {
setProfileError(t('config.account.profilePicture.sizeError', 'Please select an image smaller than 2MB.'));
setCropperOpen(false);
setCropperFile(null);
return;
}
setProfileUploading(true);
setProfileError(null);
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');
} else {
// Mark as manual upload in metadata
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
await refreshProfilePicture();
}
setProfileUploading(false);
setCropperOpen(false);
setCropperFile(null);
};
const handleProfileRemove = async () => {
if (!user || !profilePath) {
return;
}
setProfileUploading(true);
setProfileError(null);
const { error } = await supabase
.storage
.from(PROFILE_BUCKET)
.remove([profilePath]);
if (error) {
setProfileError(error.message || 'Failed to remove profile picture');
} else {
// Clear metadata when removing picture
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
await refreshProfilePicture();
}
setProfileUploading(false);
};
const handleUseCustomPicture = async () => {
if (!user) {
return;
}
setProfileUploading(true);
setProfileError(null);
try {
// Update metadata to allow manual uploads
await updateProfilePictureMetadata(user.id, {
source: 'upload',
provider: null,
});
await refreshProfilePictureMetadata();
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: any) {
setProfileError(error.message || 'Failed to switch to custom picture');
} finally {
setProfileUploading(false);
}
};
const handleEmailUpgrade = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setUpgradeError('Email is required');
return;
}
try {
setIsLoading(true);
setUpgradeError(null);
setSuccess(null);
// First, upgrade the account in Supabase
await linkEmailIdentity(email.trim(), password || undefined);
// Synchronize with backend database (using "email" as auth method for email/password)
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('');
} catch (err: any) {
setUpgradeError(err?.message || 'Failed to upgrade account');
} finally {
setIsLoading(false);
}
};
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);
// Redirect back to homepage after OAuth completes
// The UseSession hook will handle the pendingUpgrade synchronization
const redirectUrl = absoluteWithBasePath('/auth/callback?next=/');
const result = await linkOAuthIdentity(provider, redirectUrl);
if (result.data?.url) {
window.location.href = result.data.url;
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : `Failed to upgrade account with ${provider}`;
setUpgradeError(errorMessage);
setIsLoading(false);
sessionStorage.removeItem('pendingUpgrade');
sessionStorage.removeItem('upgradeProvider');
}
};
const handleDeleteAccount = async () => {
if (isAnonymous) return;
try {
setIsDeletingAccount(true);
await deleteCurrentAccount();
setDeleteModalOpen(false);
setConfirmEmail('');
await signOut();
window.location.href = absoluteWithBasePath('/login');
} catch (err) {
const fallbackMessage = t('config.account.overview.deleteFailed', 'Failed to delete account.');
const message = err instanceof Error ? err.message : fallbackMessage;
console.error('[Overview] Delete account failed:', err);
showToast({
alertType: 'error',
title: t('config.account.overview.deleteFailedTitle', 'Unable to delete account'),
body: message,
expandable: true,
location: 'top-right',
durationMs: 7000
});
} finally {
setIsDeletingAccount(false);
}
};
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setConfirmEmail('');
};
return (
<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>
<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' }}>
{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')
}
</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>
)}
</div>
<Button color="red" variant="filled" onClick={onLogoutClick}>
{t('logOut', 'Log out')}
</Button>
</div>
</div>
<Divider />
<div>
<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>
{profileError && (
<Alert color="red" mb="md">
{profileError}
</Alert>
)}
{success && (
<Alert color="green" mb="md">
{success}
</Alert>
)}
{isOAuthPicture ? (
<Group align="center" gap="md">
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<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'
})}
</Text>
<Button variant="outline" onClick={handleUseCustomPicture} disabled={profileUploading}>
{t('config.account.profilePicture.useCustom', 'Use custom picture')}
</Button>
</div>
</Group>
) : (
<Group align="center" gap="md">
<Avatar src={profilePictureUrl || undefined} radius="xl" size={72} color="blue">
{profileInitial}
</Avatar>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Group gap="sm">
<FileButton
onChange={handleProfileUpload}
accept="image/png,image/jpeg,image/webp"
disabled={!user || profileUploading}
>
{(props) => (
<Button {...props} loading={profileUploading}>
{t('config.account.profilePicture.upload', 'Upload')}
</Button>
)}
</FileButton>
<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.')}
</Text>
</div>
</Group>
)}
</div>
<ProfilePictureCropper
file={cropperFile}
opened={cropperOpen}
onClose={() => {
setCropperOpen(false);
setCropperFile(null);
}}
onCropComplete={handleCropComplete}
/>
{isAnonymous && <Divider />}
{isAnonymous && (
<div>
<div>
<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>
</div>
{upgradeError && (
<Alert color="red" mb="md">
{upgradeError}
</Alert>
)}
{success && (
<Alert color="green" mb="md">
{success}
</Alert>
)}
<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')}
</Text>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{oauthProviders
.filter(provider => !provider.isDisabled)
.map((provider) => (
<Tooltip
key={provider.id}
content={`${t('config.account.upgrade.linkWith', 'Link with')} ${provider.label}`}
>
<Button
variant="outline"
size="sm"
leftSection={
<Image
src={`${BASE_PATH}/Login/${provider.file}`}
alt={provider.label}
style={{ width: 16, height: 16 }}
/>
}
onClick={() => handleOAuthUpgrade(provider.id as any)}
disabled={isLoading}
>
{provider.label}
</Button>
</Tooltip>
))}
</div>
</div>
<div>
<Text size="sm" fw={500} mb="xs" c="var(--mantine-color-dimmed)">
{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')}
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')}
value={password}
onChange={(e) => setPassword(e.target.value)}
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')}
</Button>
</Group>
</form>
</div>
</div>
)}
{/* 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')}
</Button>
</div>
)}
{/* Delete Account Confirmation Modal */}
<Modal
opened={deleteModalOpen}
onClose={closeDeleteModal}
title={t('config.account.overview.deleteAccountTitle', 'Delete Account')}
centered
zIndex={10000}
>
<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.')}
</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 })}
</Text>
<TextInput
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')}
</Button>
<Button
color="red"
disabled={confirmEmail.toLowerCase() !== user?.email?.toLowerCase()}
type="submit"
loading={isDeletingAccount}
>
{t('config.account.overview.confirmDelete', 'Delete My Account')}
</Button>
</Group>
</form>
</Modal>
</div>
);
};
export default Overview;
@@ -0,0 +1,123 @@
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();
const { refreshSession } = useAuth();
const [opened, setOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false);
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'));
return;
}
if (newPassword.length < 6) {
setError(t('signup.passwordTooShort', 'Password must be at least 6 characters long'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('signup.passwordsDoNotMatch', 'Passwords do not match'));
return;
}
try {
setIsLoading(true);
setError(null);
setSuccess(null);
// Update to the new password directly
const { error: updateError } = await supabase.auth.updateUser({ password: newPassword });
if (updateError) {
setError(updateError.message);
return;
}
setSuccess(t('login.passwordUpdatedSuccess', 'Your password has been updated successfully.'));
setNewPassword('');
setConfirmPassword('');
setDidUpdate(true);
// Replace form with success text, then close after 2s
setTimeout(() => {
// refresh session after closing to avoid UI jank
void refreshSession();
setOpened(false);
setDidUpdate(false);
}, 2000);
} catch (e: any) {
setError(e?.message || 'Failed to change password');
} finally {
setIsLoading(false);
}
};
return (
<div style={{ position: 'relative' }}>
<LoadingOverlay visible={isLoading} />
<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>
</div>
<Button type="button" onClick={() => setOpened(true)} variant="filled">
{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}>
{error && (
<Alert color="red" mb="md">{error}</Alert>
)}
{didUpdate ? (
<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' }}>
<PasswordInput
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')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
<Divider my="sm" />
<Group justify="flex-end">
<Button type="button" variant="default" onClick={() => setOpened(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" onClick={handleChangePassword} loading={isLoading}>
{t('config.account.security.update', 'Update password')}
</Button>
</Group>
</div>
)}
</Modal>
</div>
);
};
export default PasswordSecurity;
@@ -0,0 +1,201 @@
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 [selectedCreditsPack, setSelectedCreditsPack] = useState<CreditsPack>(null);
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, £)' }
];
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.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
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);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
}, [selectedPlan, purchaseType, updateCurrentPlan]);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, []);
const handleAddPaymentClick = useCallback(() => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(plan => plan.id === 'pro');
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType('subscription');
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
// Check URL parameters for action=add-payment
useEffect(() => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'add-payment') {
handleAddPaymentClick();
// Clean up URL
params.delete('action');
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}, [data, handleAddPaymentClick]);
// Early returns after all hooks are called
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<Loader size="lg" />
</div>
);
}
if (error) {
return (
<Alert color="red" title="Error loading plans">
{error}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = data;
const plansArray = Array.from(plans.values());
return (
<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>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={true}
w={300}
/>
</Group>
</div>
<ActivePlanSection
currentPlan={currentPlan}
_activeSince={activeSince}
_nextBillingDate={nextBillingDate}
trialStatus={trialStatus ?? undefined}
onAddPaymentClick={handleAddPaymentClick}
/>
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
<ApiPackagesSection
apiPackages={apiPackages}
selectedCredits={selectedCredits}
onSelectedCreditsChange={setSelectedCredits}
onCreditPurchaseClick={handleCreditPurchaseClick}
/>
{/* Stripe Checkout Modal */}
<StripeCheckout
opened={checkoutOpen && (selectedPlan !== null || selectedCreditsPack !== null)}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
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 || ''
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={trialStatus?.isTrialing && purchaseType === 'subscription'}
/>
</div>
);
};
export default Plan;
@@ -0,0 +1,139 @@
import React from "react";
import {
Paper,
Stack,
Group,
Text,
Divider,
} from "@mantine/core";
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
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";
import { useTranslation } from "react-i18next";
// Using shared ApiCredits type as ApiUsage
interface UsageSectionProps {
apiUsage: ApiUsage;
obscured?: boolean;
overlayMessage?: string;
loading?: boolean;
}
export default function UsageSection({ apiUsage, obscured, overlayMessage, loading }: UsageSectionProps) {
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);
const formatDate = (iso: string, withTime: boolean) => formatUTC(iso, withTime);
// Prepare data for the stacked bar chart
const fractions: FractionData[] = [
{
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)"
},
{
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)"
},
];
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}
style={{
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
)}
</Text>
</Group>
<StackedBarChart
fractions={fractions}
width={640}
height={22}
showLegend={true}
tooltipPosition="top"
loading={Boolean(loading)}
animate={!loading}
animationDurationMs={900}
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}}'
})}
/>
<Divider my={4} />
<Group justify="space-between" wrap="wrap">
<Group gap="lg">
<Text size="sm" c="dimmed">
{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)
)}
</Text>
</Group>
</Group>
</Stack>
</Paper>
{obscured && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: 16,
color: "var(--mantine-color-text)",
background: "rgba(16, 18, 27, 0.55)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<Text size="sm" c="dimmed">
{overlayMessage || t('config.apiKeys.overlayMessage', 'Generate a key to see credits and available credits')}
</Text>
</div>
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
export function useApiKey() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [apiKey, setApiKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const fetchKey = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Backend is POST for get and update
const res = await apiClient.post("/api/v1/user/get-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
// If not found, try to create one by calling update endpoint
if (e?.response?.status === 404) {
try {
const createRes = await apiClient.post("/api/v1/user/update-api-key");
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
if (typeof created === "string") setApiKey(created);
} catch (createErr: any) {
setError(createErr);
}
} else {
setError(e);
}
} finally {
setIsLoading(false);
setHasAttempted(true);
}
}, []);
const refresh = useCallback(async () => {
setIsRefreshing(true);
setError(null);
try {
const res = await apiClient.post("/api/v1/user/update-api-key");
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") setApiKey(value);
} catch (e: any) {
setError(e);
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchKey();
}
}, [loading, session, hasAttempted, isAnonymous, fetchKey]);
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
}
export default useApiKey;
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { ApiCredits } from "@app/types/credits";
import { isUserAnonymous } from "@app/auth/supabase";
function coerceNumber(value: unknown, fallback = 0): number {
const n = typeof value === "string" ? Number(value) : (value as number);
return Number.isFinite(n) ? (n as number) : fallback;
}
function normalizeCredits(raw: any): 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:
(raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date) || "",
lastApiUsage:
(raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at) || "",
};
}
export function useCredits() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [data, setData] = useState<ApiCredits | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const fetchCredits = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res = await apiClient.get("/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;
setData(isEmpty ? null : normalized);
} catch (e: any) {
setError(e);
} finally {
setIsLoading(false);
setHasAttempted(true);
}
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchCredits();
}
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
return { data, isLoading, error, refetch: fetchCredits, hasAttempted } as const;
}
export default useCredits;
@@ -0,0 +1,125 @@
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;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
interface ActivePlanSectionProps {
currentPlan: PlanTier;
_activeSince?: string;
_nextBillingDate?: string;
trialStatus?: TrialStatus;
onAddPaymentClick?: () => void;
}
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
currentPlan,
_activeSince,
_nextBillingDate,
trialStatus,
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>
<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>
{/* Trial Status Alert */}
{trialStatus?.isTrialing && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t('plan.trial.title', 'Free Trial Active')}
>
<Text size="sm">
{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()
})}
</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()
})}
</Text>
) : (
onAddPaymentClick && (
<Button
size="xs"
variant="light"
mt="sm"
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t('plan.trial.subscribeToPro', 'Subscribe to Pro')}
</Button>
)
)}
</Alert>
)}
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t('plan.trial.badge', 'Trial')}
</Badge>
)}
</Group>
{/* {activeSince && (
<Text size="sm" c="dimmed">
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
</Text>
)} */}
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
</Text>
)} */}
</div>
</Group>
</Card>
</div>
);
};
export default ActivePlanSection;
@@ -0,0 +1,101 @@
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;
name: string;
credits: number;
price: number;
currency: string;
description: string;
}
interface ApiPackagesSectionProps {
apiPackages: ApiPackage[];
selectedCredits: number;
onSelectedCreditsChange: (value: number) => void;
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
}
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
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>
<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">
<Stack gap="xl">
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t('plan.selectCredits', 'Select Credit Amount')}
</Text>
<div className="px-4">
<Slider
value={selectedCredits}
onChange={onSelectedCreditsChange}
onChangeEnd={(value) => onSelectedCreditsChange(Math.round(value))}
min={0}
max={3}
step={0.01}
marks={[
{ value: 0, label: '100' },
{ value: 1, label: '500' },
{ value: 2, label: '1K' },
{ value: 3, label: '5K' }
]}
size="lg"
className="mb-6"
label={null}
/>
</div>
</div>
{/* Selected Package Display */}
<Flex gap={"xl"} justify="space-between" align="center">
<div>
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].credits.toLocaleString()} Credits
</Text>
<Text size="sm" c="dimmed">
{apiPackages[Math.round(selectedCredits)].description}
</Text>
</div>
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t('plan.totalCost', 'Total Cost')}
</Text>
</div>
<Button
size="lg"
onClick={() => onCreditPurchaseClick(apiPackages[Math.round(selectedCredits)].id as CreditsPack)}
>
{t('plan.purchase', 'Purchase')}
</Button>
</Flex>
</Stack>
</Card>
</div>
);
};
export default ApiPackagesSection;
@@ -0,0 +1,123 @@
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[];
currentPlan?: PlanTier;
currentLicenseInfo?: any;
onUpgradeClick: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
onManageClick?: (plan: PlanTier | any) => void; // Accept PlanTierGroup for compatibility
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: Array<{ value: string; label: string }>;
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
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>
<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 => (
<PlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentPlan?.id}
isUserProOrAbove={isUserProOrAbove}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? 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')}
</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 key={plan.id} className="text-center p-2 min-w-24 relative">
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
style={{
position: 'absolute',
top: '0rem',
right: '-2rem',
fontSize: '0.5rem',
fontWeight: '500',
height: '1rem',
padding: '0 0.1rem'
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
</th>
))}
</tr>
</thead>
<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 key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}></Text>
) : (
<Text c="gray">-</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,98 @@
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?: any; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: any;
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: any) => void; // Accept PlanTierGroup or PlanTier
onManageClick?: (plan: any) => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan: propPlan,
planGroup,
isCurrentPlan,
isCurrentTier: _isCurrentTier,
isDowngrade: _isDowngrade,
isUserProOrAbove,
currentLicenseInfo: _currentLicenseInfo,
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || (planGroup as any)?.monthly || (planGroup as any)?.yearly;
const { t } = useTranslation();
if (!plan) return null; // Safety check
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>
)}
<Stack gap="md" className="h-full">
<div>
<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}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight: string, index: number) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div className="flex-grow" />
{!shouldHideUpgrade && (
<Button
variant={isCurrentPlan ? "filled" : plan.isContactOnly ? "outline" : "filled"}
disabled={isCurrentPlan}
fullWidth
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t('plan.current', 'Current Plan')
: plan.isContactOnly
? t('plan.contact', 'Get in Touch')
: t('plan.upgrade', 'Upgrade')
}
</Button>
)}
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,153 @@
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';
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
interface CreateSaasConfigNavSectionsOptions {
isDev?: boolean;
isAnonymous?: boolean;
t: TFunction<'translation', undefined>;
}
function ensurePreferencesSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const preferencesIndex = sections.findIndex(section => section.title === 'Preferences');
if (preferencesIndex === -1) {
return [
...sections,
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />,
},
],
},
];
}
return sections;
}
function appendDeveloperSection(sections: ConfigNavSection[]): ConfigNavSection[] {
const hasDeveloper = sections.some(section =>
section.items.some(item => item.key === 'developer' || item.key === 'api-keys'),
);
if (hasDeveloper) {
return sections;
}
return [
...sections,
{
title: 'Developer',
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
component: <ApiKeys />,
},
],
},
];
}
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;
}
return [
...sections,
{
title: 'Billing',
items: [
{
key: 'plan',
label: t('config.plan', 'Plan'),
icon: 'credit-card',
component: <Plan />,
},
],
},
];
}
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false);
// Create Account section as the first section with Overview and Passwords & Security
const accountSection: ConfigNavSection = {
title: t('config.account.overview.title', 'Account Settings'),
items: [
{
key: 'overview',
label: t('config.account.overview.label', 'Overview'),
icon: 'account-circle',
component: <Overview onLogoutClick={onLogoutClick} />,
},
{
key: 'security',
label: 'Passwords & Security',
icon: 'lock',
component: <PasswordSecurity />,
},
],
};
let sections = [accountSection, ...baseSections];
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
sections = sections.map(section => ({
...section,
items: section.items.map(item =>
item.key === 'general'
? { ...item, component: <GeneralSection hideUpdateSection hideAdminBanner /> }
: item
),
}));
sections = ensurePreferencesSection(sections);
sections = appendDeveloperSection(sections);
if (!isAnonymous) {
sections = appendBillingSection(sections, t);
}
if (isDev) {
console.debug('[AppConfigModal] SaaS navigation sections', sections);
}
return sections;
}
@@ -0,0 +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'
] 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';
// some of these are not used yet, but appear in figma designs
@@ -0,0 +1,14 @@
export function formatUTC(iso: string, withTime: boolean): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "-";
const formatted = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
...(withTime ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
timeZone: "UTC",
}).format(date);
return withTime ? `${formatted} UTC` : formatted;
}
@@ -0,0 +1,209 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
display: flex;
gap: 12px;
pointer-events: none;
}
.toast-container--top-left {
top: 16px;
left: 16px;
flex-direction: column;
}
.toast-container--top-right {
top: 16px;
right: 16px;
flex-direction: column;
}
.toast-container--bottom-left {
bottom: 16px;
left: 16px;
flex-direction: column-reverse;
}
.toast-container--bottom-right {
bottom: 16px;
right: 16px;
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
max-width: 560px;
box-shadow: var(--shadow-lg);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
color: var(--text-primary);
border: 1px solid var(--color-green-400);
}
.toast-item--error {
background: var(--color-red-100);
color: var(--text-primary);
border: 1px solid var(--color-red-400);
}
.toast-item--warning {
background: var(--color-orange-100); /* deeper orange background */
color: var(--text-primary);
border: 1px solid var(--color-orange-300); /* deeper orange border */
}
.toast-item--neutral {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
/* Toast Header Row */
.toast-header {
display: flex;
align-items: center;
gap: 12px;
}
.toast-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.toast-title-container {
font-weight: 700;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.toast-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
color: inherit;
font-size: 12px;
font-weight: 700;
}
.toast-controls {
display: flex;
gap: 4px;
align-items: center;
}
.toast-button {
width: 28px;
height: 28px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.toast-expand-button {
transform: rotate(0deg);
transition: transform 160ms ease;
}
.toast-expand-button--expanded {
transform: rotate(180deg);
}
/* Progress Bar */
.toast-progress-container {
margin-top: 8px;
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
transition: width 160ms ease;
}
.toast-progress-bar--success {
background: var(--color-green-500);
}
.toast-progress-bar--error {
background: var(--color-red-500);
}
.toast-progress-bar--warning {
background: var(--color-orange-400); /* deeper orange for progress */
}
.toast-progress-bar--neutral {
background: var(--color-gray-500);
}
/* Toast Body */
.toast-body {
font-size: 14px;
opacity: 0.9;
margin-top: 8px;
}
/* Toast Action Button */
.toast-action-container {
margin-top: 12px;
display: flex;
justify-content: flex-start;
}
.toast-action-button {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid;
background: transparent;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.toast-action-button--success {
color: var(--text-primary);
border-color: var(--color-green-400);
}
.toast-action-button--error {
color: var(--text-primary);
border-color: var(--color-red-400);
}
.toast-action-button--warning {
color: var(--text-primary);
border-color: var(--color-orange-300);
}
.toast-action-button--neutral {
color: var(--text-primary);
border-color: var(--border-default);
}
File diff suppressed because it is too large Load Diff