Files
smoothschedule/frontend/src/components/ConnectOnboardingEmbed.tsx
poduck f1b1f18bc5 Add Stripe notifications, messaging improvements, and code cleanup
Stripe Notifications:
- Add periodic task to check Stripe Connect accounts for requirements
- Create in-app notifications for business owners when action needed
- Add management command to setup Stripe periodic tasks
- Display Stripe notifications with credit card icon in notification bell
- Navigate to payments page when Stripe notification clicked

Messaging Improvements:
- Add "Everyone" option to broadcast message recipients
- Allow sending messages to yourself (remove self-exclusion)
- Fix broadcast message ID not returned after creation
- Add real-time websocket support for broadcast notifications
- Show toast when broadcast message received via websocket

UI Fixes:
- Remove "View all" button from notifications (no page exists)
- Add StripeNotificationBanner component for Connect alerts
- Connect useUserNotifications hook in TopBar for app-wide websocket

Code Cleanup:
- Remove legacy automations app and plugin system
- Remove safe_scripting module (moved to Activepieces)
- Add migration to remove plugin-related models
- Various test improvements and coverage additions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 15:35:53 -05:00

388 lines
15 KiB
TypeScript

/**
* Embedded Stripe Connect Onboarding Component
*
* Uses Stripe's Connect embedded components to provide a seamless
* onboarding experience without redirecting users away from the app.
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
ConnectComponentsProvider,
ConnectAccountOnboarding,
} from '@stripe/react-connect-js';
import { loadConnectAndInitialize } from '@stripe/connect-js';
import type { StripeConnectInstance } from '@stripe/connect-js';
import {
CheckCircle,
AlertCircle,
Loader2,
CreditCard,
Wallet,
Building2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
import { useDarkMode } from '../hooks/useDarkMode';
// Get appearance config based on dark mode
const getAppearance = (isDark: boolean) => ({
overlays: 'drawer' as const,
variables: {
// Brand colors - using your blue theme
colorPrimary: '#3b82f6', // brand-500
colorBackground: isDark ? '#1f2937' : '#ffffff', // gray-800 / white
colorText: isDark ? '#f9fafb' : '#111827', // gray-50 / gray-900
colorSecondaryText: isDark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
colorBorder: isDark ? '#374151' : '#e5e7eb', // gray-700 / gray-200
colorDanger: '#ef4444', // red-500
// Typography - matching Inter font
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
fontSizeSm: '12px',
fontSizeLg: '16px',
fontSizeXl: '18px',
fontWeightNormal: '400',
fontWeightMedium: '500',
fontWeightBold: '600',
// Spacing & Borders - matching your rounded-lg style
spacingUnit: '12px',
borderRadius: '8px',
// Form elements
formBackgroundColor: isDark ? '#111827' : '#f9fafb', // gray-900 / gray-50
formBorderColor: isDark ? '#374151' : '#d1d5db', // gray-700 / gray-300
formHighlightColorBorder: '#3b82f6', // brand-500
formAccentColor: '#3b82f6', // brand-500
// Buttons
buttonPrimaryColorBackground: '#3b82f6', // brand-500
buttonPrimaryColorText: '#ffffff',
buttonSecondaryColorBackground: isDark ? '#374151' : '#f3f4f6', // gray-700 / gray-100
buttonSecondaryColorText: isDark ? '#f9fafb' : '#374151', // gray-50 / gray-700
buttonSecondaryColorBorder: isDark ? '#4b5563' : '#d1d5db', // gray-600 / gray-300
// Action colors
actionPrimaryColorText: '#3b82f6', // brand-500
actionSecondaryColorText: isDark ? '#9ca3af' : '#6b7280', // gray-400 / gray-500
// Badge colors
badgeNeutralColorBackground: isDark ? '#374151' : '#f3f4f6', // gray-700 / gray-100
badgeNeutralColorText: isDark ? '#d1d5db' : '#4b5563', // gray-300 / gray-600
badgeSuccessColorBackground: isDark ? '#065f46' : '#d1fae5', // green-800 / green-100
badgeSuccessColorText: isDark ? '#6ee7b7' : '#065f46', // green-300 / green-800
badgeWarningColorBackground: isDark ? '#92400e' : '#fef3c7', // amber-800 / amber-100
badgeWarningColorText: isDark ? '#fcd34d' : '#92400e', // amber-300 / amber-800
badgeDangerColorBackground: isDark ? '#991b1b' : '#fee2e2', // red-800 / red-100
badgeDangerColorText: isDark ? '#fca5a5' : '#991b1b', // red-300 / red-800
// Offset background (used for layered sections)
offsetBackgroundColor: isDark ? '#111827' : '#f9fafb', // gray-900 / gray-50
},
});
interface ConnectOnboardingEmbedProps {
connectAccount: ConnectAccountInfo | null;
tier: string;
onComplete?: () => void;
onError?: (error: string) => void;
}
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
connectAccount,
tier,
onComplete,
onError,
}) => {
const { t } = useTranslation();
const isDark = useDarkMode();
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Track the theme that was used when initializing
const initializedThemeRef = useRef<boolean | null>(null);
// Flag to trigger auto-reinitialize
const [needsReinit, setNeedsReinit] = useState(false);
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
// Detect theme changes when onboarding is already open
useEffect(() => {
if (loadingState === 'ready' && initializedThemeRef.current !== null && initializedThemeRef.current !== isDark) {
// Theme changed while onboarding is open - trigger reinitialize
setNeedsReinit(true);
}
}, [isDark, loadingState]);
// Handle reinitialization
useEffect(() => {
if (needsReinit) {
setStripeConnectInstance(null);
initializedThemeRef.current = null;
setNeedsReinit(false);
// Re-run initialization
(async () => {
setLoadingState('loading');
setErrorMessage(null);
try {
const response = await createAccountSession();
const { client_secret, publishable_key } = response.data;
const instance = await loadConnectAndInitialize({
publishableKey: publishable_key,
fetchClientSecret: async () => client_secret,
appearance: getAppearance(isDark),
});
setStripeConnectInstance(instance);
setLoadingState('ready');
initializedThemeRef.current = isDark;
} catch (err: any) {
console.error('Failed to reinitialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
})();
}
}, [needsReinit, isDark, t, onError]);
// Initialize Stripe Connect (user-triggered)
const initializeStripeConnect = useCallback(async () => {
if (loadingState === 'loading' || loadingState === 'ready') return;
setLoadingState('loading');
setErrorMessage(null);
try {
// Fetch account session from our backend
const response = await createAccountSession();
const { client_secret, publishable_key } = response.data;
// Initialize the Connect instance with theme-aware appearance
const instance = await loadConnectAndInitialize({
publishableKey: publishable_key,
fetchClientSecret: async () => client_secret,
appearance: getAppearance(isDark),
});
setStripeConnectInstance(instance);
setLoadingState('ready');
initializedThemeRef.current = isDark;
} catch (err: any) {
console.error('Failed to initialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError, t, isDark]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
// Refresh status from Stripe to sync the local database
try {
await refreshConnectStatus();
} catch (err) {
console.error('Failed to refresh Connect status:', err);
}
setLoadingState('complete');
onComplete?.();
}, [onComplete]);
// Handle errors from the Connect component
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
console.error('Connect component load error:', loadError);
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError, t]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return t('payments.standardConnect');
case 'express':
return t('payments.expressConnect');
case 'custom':
return t('payments.customConnect');
default:
return t('payments.connect');
}
};
// If account is already active, show status
if (isActive) {
return (
<div className="space-y-6">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">{t('payments.status')}:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">{t('payments.charges')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
{t('payments.enabled')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
</span>
</div>
</div>
</div>
</div>
);
}
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">{t('payments.onboardingComplete')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
{t('payments.stripeSetupComplete')}
</p>
</div>
);
}
// Error state
if (loadingState === 'error') {
return (
<div className="space-y-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
</div>
<button
onClick={() => {
setLoadingState('idle');
setErrorMessage(null);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
{t('payments.tryAgain')}
</button>
</div>
);
}
// Idle state - show start button
if (loadingState === 'idle') {
return (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
{t('payments.pciCompliance')}
</li>
</ul>
</div>
</div>
</div>
<button
onClick={initializeStripeConnect}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors"
>
<CreditCard size={18} />
{t('payments.startPaymentSetup')}
</button>
</div>
);
}
// Loading state
if (loadingState === 'loading') {
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-brand-500 mb-4" size={40} />
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
</div>
);
}
// Ready state - show embedded onboarding
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.completeAccountSetup')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('payments.fillOutInfoForPayment')}
</p>
</div>
<div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}
onLoadError={handleLoadError}
/>
</ConnectComponentsProvider>
</div>
</div>
);
}
return null;
};
export default ConnectOnboardingEmbed;