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:
@@ -5,11 +5,13 @@ import { useInvitationByToken, useAcceptInvitation } from '../hooks/usePlatform'
|
|||||||
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
||||||
|
|
||||||
// Loading step configuration for the animated progress
|
// Loading step configuration for the animated progress
|
||||||
|
// The last step stays active (spinning) until the API returns
|
||||||
const LOADING_STEPS = [
|
const LOADING_STEPS = [
|
||||||
{ id: 'account', label: 'Creating your account', icon: User, duration: 800 },
|
{ id: 'account', label: 'Creating your account', icon: User, duration: 2000 },
|
||||||
{ id: 'database', label: 'Setting up your database', icon: Database, duration: 3000 },
|
{ id: 'database', label: 'Setting up your database', icon: Database, duration: 8000 },
|
||||||
{ id: 'config', label: 'Configuring your workspace', icon: Settings, duration: 1500 },
|
{ id: 'config', label: 'Configuring your workspace', icon: Settings, duration: 6000 },
|
||||||
{ id: 'features', label: 'Activating features', icon: Sparkles, duration: 1000 },
|
{ 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 = () => {
|
const TenantOnboardPage: React.FC = () => {
|
||||||
@@ -24,7 +26,9 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [creationComplete, setCreationComplete] = useState(false);
|
const [creationComplete, setCreationComplete] = useState(false);
|
||||||
const [loadingStepIndex, setLoadingStepIndex] = useState(0);
|
const [loadingStepIndex, setLoadingStepIndex] = useState(0);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const loadingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const loadingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const elapsedTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
// Step 1: Account
|
// Step 1: Account
|
||||||
email: '',
|
email: '',
|
||||||
@@ -125,29 +129,54 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
setCurrentStep(prev => prev - 1);
|
setCurrentStep(prev => prev - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup loading interval on unmount
|
// Cleanup timers on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (loadingIntervalRef.current) {
|
if (loadingIntervalRef.current) {
|
||||||
clearTimeout(loadingIntervalRef.current);
|
clearTimeout(loadingIntervalRef.current);
|
||||||
}
|
}
|
||||||
|
if (elapsedTimerRef.current) {
|
||||||
|
clearInterval(elapsedTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Animate through loading steps
|
// Animate through loading steps
|
||||||
const startLoadingAnimation = () => {
|
const startLoadingAnimation = () => {
|
||||||
setLoadingStepIndex(0);
|
setLoadingStepIndex(0);
|
||||||
|
setElapsedSeconds(0);
|
||||||
let stepIndex = 0;
|
let stepIndex = 0;
|
||||||
|
|
||||||
|
// Start elapsed time counter
|
||||||
|
elapsedTimerRef.current = setInterval(() => {
|
||||||
|
setElapsedSeconds(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
const advanceStep = () => {
|
const advanceStep = () => {
|
||||||
stepIndex++;
|
stepIndex++;
|
||||||
if (stepIndex < LOADING_STEPS.length) {
|
if (stepIndex < LOADING_STEPS.length) {
|
||||||
setLoadingStepIndex(stepIndex);
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
@@ -172,10 +201,7 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
{ token, data },
|
{ token, data },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Clear the loading animation
|
stopLoadingAnimation();
|
||||||
if (loadingIntervalRef.current) {
|
|
||||||
clearTimeout(loadingIntervalRef.current);
|
|
||||||
}
|
|
||||||
// Show all steps as complete, then show success
|
// Show all steps as complete, then show success
|
||||||
setLoadingStepIndex(LOADING_STEPS.length);
|
setLoadingStepIndex(LOADING_STEPS.length);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -184,10 +210,7 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
// Clear the loading animation
|
stopLoadingAnimation();
|
||||||
if (loadingIntervalRef.current) {
|
|
||||||
clearTimeout(loadingIntervalRef.current);
|
|
||||||
}
|
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setAcceptError(
|
setAcceptError(
|
||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
@@ -513,11 +536,11 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
Creating Your Business
|
Creating Your Business
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{LOADING_STEPS.map((step, index) => {
|
{LOADING_STEPS.map((step, index) => {
|
||||||
const StepIcon = step.icon;
|
const StepIcon = step.icon;
|
||||||
const isComplete = index < loadingStepIndex;
|
const isComplete = index < loadingStepIndex;
|
||||||
@@ -526,7 +549,7 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
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
|
isComplete
|
||||||
? 'bg-green-50 dark:bg-green-900/20'
|
? 'bg-green-50 dark:bg-green-900/20'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
@@ -535,25 +558,25 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
isComplete
|
||||||
? 'bg-green-500 text-white'
|
? 'bg-green-500 text-white'
|
||||||
: isCurrent
|
: 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'
|
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<CheckCircle size={20} />
|
<CheckCircle size={18} />
|
||||||
) : isCurrent ? (
|
) : isCurrent ? (
|
||||||
<Loader size={20} className="animate-spin" />
|
<Loader size={18} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<StepIcon size={20} />
|
<StepIcon size={18} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p
|
<p
|
||||||
className={`font-medium transition-colors duration-300 ${
|
className={`font-medium text-sm transition-colors duration-300 ${
|
||||||
isComplete
|
isComplete
|
||||||
? 'text-green-700 dark:text-green-400'
|
? 'text-green-700 dark:text-green-400'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
@@ -570,14 +593,42 @@ const TenantOnboardPage: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress section with time */}
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
<div className="space-y-2">
|
||||||
<div
|
<div className="flex justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-500 ease-out"
|
<span>
|
||||||
style={{
|
{loadingStepIndex < LOADING_STEPS.length - 1
|
||||||
width: `${((loadingStepIndex + 1) / LOADING_STEPS.length) * 100}%`,
|
? `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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user