fix(onboarding): Improve loading indicator with elapsed time and better pacing

- Add elapsed time counter (MM:SS)
- Spread animation steps over ~30 seconds before final step
- Final step stays spinning (doesn't complete early)
- Progress bar caps at 90% until actually done, pulses on final step
- Show "Finalizing..." and helpful message during long final step
- Clear "45-90 seconds" time estimate upfront

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-03 16:37:34 -05:00
parent 567fe0604a
commit 0d3c97ea5f

View File

@@ -5,11 +5,13 @@ import { useInvitationByToken, useAcceptInvitation } from '../hooks/usePlatform'
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
// Loading step configuration for the animated progress
// The last step stays active (spinning) until the API returns
const LOADING_STEPS = [
{ id: 'account', label: 'Creating your account', icon: User, duration: 800 },
{ id: 'database', label: 'Setting up your database', icon: Database, duration: 3000 },
{ id: 'config', label: 'Configuring your workspace', icon: Settings, duration: 1500 },
{ id: 'features', label: 'Activating features', icon: Sparkles, duration: 1000 },
{ id: 'account', label: 'Creating your account', icon: User, duration: 2000 },
{ id: 'database', label: 'Setting up your database', icon: Database, duration: 8000 },
{ id: 'config', label: 'Configuring your workspace', icon: Settings, duration: 6000 },
{ id: 'migrations', label: 'Running database migrations', icon: Database, duration: 15000 },
{ id: 'features', label: 'Activating features', icon: Sparkles, duration: null }, // null = stays spinning until done
];
const TenantOnboardPage: React.FC = () => {
@@ -24,7 +26,9 @@ const TenantOnboardPage: React.FC = () => {
const [isCreating, setIsCreating] = useState(false);
const [creationComplete, setCreationComplete] = useState(false);
const [loadingStepIndex, setLoadingStepIndex] = useState(0);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const loadingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const elapsedTimerRef = useRef<NodeJS.Timeout | null>(null);
const [formData, setFormData] = useState({
// Step 1: Account
email: '',
@@ -125,29 +129,54 @@ const TenantOnboardPage: React.FC = () => {
setCurrentStep(prev => prev - 1);
};
// Cleanup loading interval on unmount
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (loadingIntervalRef.current) {
clearTimeout(loadingIntervalRef.current);
}
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
}
};
}, []);
// Animate through loading steps
const startLoadingAnimation = () => {
setLoadingStepIndex(0);
setElapsedSeconds(0);
let stepIndex = 0;
// Start elapsed time counter
elapsedTimerRef.current = setInterval(() => {
setElapsedSeconds(prev => prev + 1);
}, 1000);
const advanceStep = () => {
stepIndex++;
if (stepIndex < LOADING_STEPS.length) {
setLoadingStepIndex(stepIndex);
loadingIntervalRef.current = setTimeout(advanceStep, LOADING_STEPS[stepIndex].duration);
const nextDuration = LOADING_STEPS[stepIndex].duration;
// If duration is null, this step stays active until API returns
if (nextDuration !== null) {
loadingIntervalRef.current = setTimeout(advanceStep, nextDuration);
}
}
};
loadingIntervalRef.current = setTimeout(advanceStep, LOADING_STEPS[0].duration);
const firstDuration = LOADING_STEPS[0].duration;
if (firstDuration !== null) {
loadingIntervalRef.current = setTimeout(advanceStep, firstDuration);
}
};
const stopLoadingAnimation = () => {
if (loadingIntervalRef.current) {
clearTimeout(loadingIntervalRef.current);
}
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
}
};
const handleSubmit = () => {
@@ -172,10 +201,7 @@ const TenantOnboardPage: React.FC = () => {
{ token, data },
{
onSuccess: () => {
// Clear the loading animation
if (loadingIntervalRef.current) {
clearTimeout(loadingIntervalRef.current);
}
stopLoadingAnimation();
// Show all steps as complete, then show success
setLoadingStepIndex(LOADING_STEPS.length);
setTimeout(() => {
@@ -184,10 +210,7 @@ const TenantOnboardPage: React.FC = () => {
}, 500);
},
onError: (error: any) => {
// Clear the loading animation
if (loadingIntervalRef.current) {
clearTimeout(loadingIntervalRef.current);
}
stopLoadingAnimation();
setIsCreating(false);
setAcceptError(
error.response?.data?.detail ||
@@ -513,11 +536,11 @@ const TenantOnboardPage: React.FC = () => {
Creating Your Business
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we set everything up for you...
This typically takes 45-90 seconds. Please don't close this page.
</p>
</div>
<div className="space-y-4">
<div className="space-y-3">
{LOADING_STEPS.map((step, index) => {
const StepIcon = step.icon;
const isComplete = index < loadingStepIndex;
@@ -526,7 +549,7 @@ const TenantOnboardPage: React.FC = () => {
return (
<div
key={step.id}
className={`flex items-center gap-4 p-4 rounded-lg transition-all duration-300 ${
className={`flex items-center gap-4 p-3 rounded-lg transition-all duration-300 ${
isComplete
? 'bg-green-50 dark:bg-green-900/20'
: isCurrent
@@ -535,25 +558,25 @@ const TenantOnboardPage: React.FC = () => {
}`}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-300 ${
isComplete
? 'bg-green-500 text-white'
: isCurrent
? 'bg-indigo-500 text-white'
? 'bg-indigo-500 text-white animate-pulse'
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
}`}
>
{isComplete ? (
<CheckCircle size={20} />
<CheckCircle size={18} />
) : isCurrent ? (
<Loader size={20} className="animate-spin" />
<Loader size={18} className="animate-spin" />
) : (
<StepIcon size={20} />
<StepIcon size={18} />
)}
</div>
<div className="flex-1">
<p
className={`font-medium transition-colors duration-300 ${
className={`font-medium text-sm transition-colors duration-300 ${
isComplete
? 'text-green-700 dark:text-green-400'
: isCurrent
@@ -570,14 +593,42 @@ const TenantOnboardPage: React.FC = () => {
})}
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-500 ease-out"
style={{
width: `${((loadingStepIndex + 1) / LOADING_STEPS.length) * 100}%`,
}}
/>
{/* Progress section with time */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-500 dark:text-gray-400">
<span>
{loadingStepIndex < LOADING_STEPS.length - 1
? `Step ${loadingStepIndex + 1} of ${LOADING_STEPS.length}`
: 'Finalizing...'}
</span>
<span className="tabular-nums">
{Math.floor(elapsedSeconds / 60)}:{(elapsedSeconds % 60).toString().padStart(2, '0')} elapsed
</span>
</div>
{/* Progress bar - doesn't go to 100% until actually done */}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ease-out ${
loadingStepIndex >= LOADING_STEPS.length - 1
? 'bg-indigo-400 animate-pulse'
: 'bg-indigo-600'
}`}
style={{
// Cap at 90% until we're actually complete
width: loadingStepIndex >= LOADING_STEPS.length
? '100%'
: `${Math.min(90, ((loadingStepIndex + 1) / LOADING_STEPS.length) * 100)}%`,
}}
/>
</div>
{/* Show extra message when on final step */}
{loadingStepIndex >= LOADING_STEPS.length - 1 && (
<p className="text-xs text-center text-gray-500 dark:text-gray-400 animate-pulse">
Almost there! Setting up your database tables...
</p>
)}
</div>
</div>
)}