mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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;
|
||||
+101
@@ -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;
|
||||
+123
@@ -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
Reference in New Issue
Block a user