78 KiB
Onboarding Module - Migration Action Plan
Overview
This document provides a detailed, step-by-step action plan for migrating the Onboarding module from the legacy ASP.NET Core MVC implementation to Next.js 14, incorporating the modern design system showcased in _examples/onboarding.
Goal: Maintain 100% feature parity while delivering an enhanced user experience with advanced animations and interactions.
Phase 1: Project Setup & Infrastructure (Days 1-2)
1.1 Environment Configuration
Task: Configure development environment for onboarding module
Steps:
- Verify Next.js app is running:
cd src/SplashPage.Web.Ui && npm run dev - Install missing dependencies (if any):
npm install @tanstack/react-query framer-motion canvas-confetti
npm install -D @types/node
- Create environment variables file:
# .env.local
NEXT_PUBLIC_API_URL=https://localhost:44311
NEXT_PUBLIC_MERAKI_DASHBOARD_URL=https://dashboard.meraki.com
Acceptance Criteria:
- ✅ Next.js dev server runs without errors
- ✅ Environment variables accessible via
process.env.NEXT_PUBLIC_* - ✅ Hot reload working
Files Modified:
.env.local(new)
1.2 API Client Setup
Task: Configure Kubb-generated API client for onboarding services
Steps:
- Verify Swagger endpoint includes onboarding services:
curl https://localhost:44311/swagger/v1/swagger.json | grep "OnboardingService"
- If not present, regenerate API client:
npm run kubb:generate
- Verify generated types exist:
// Should be auto-generated by Kubb
import {
useOnboardingServiceValidateApiKey,
useOnboardingServiceGetOrganizations,
useOnboardingServiceGetOrganizationsNetworks,
useOnboardingServiceFinishSetup,
} from '@/lib/api';
- If Kubb doesn't have onboarding endpoints, manually create API hooks (temporary solution):
// src/hooks/api/useOnboardingApi.ts
Acceptance Criteria:
- ✅ API hooks available for all 4 onboarding endpoints
- ✅ TypeScript types match backend DTOs
- ✅ React Query configured with proper cache settings
Files Created:
src/hooks/api/useOnboardingApi.ts(if manual implementation needed)
Files Modified:
kubb.config.ts(if regeneration needed)
1.3 TanStack Query Configuration
Task: Configure React Query client with optimal caching strategy
Steps:
- Check if QueryClientProvider exists in app layout:
// src/app/layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
- If not present, wrap app with provider:
'use client';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes (match backend cache)
cacheTime: 30 * 60 * 1000, // 30 minutes
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
});
export default function RootLayout({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
- Add React Query DevTools for debugging (development only):
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Inside provider
<ReactQueryDevtools initialIsOpen={false} />
Acceptance Criteria:
- ✅ QueryClient configured with correct cache times
- ✅ DevTools accessible in development
- ✅ No hydration errors in console
Files Modified:
src/app/layout.tsxorsrc/app/providers.tsx
1.4 Design System Adaptation
Task: Port design tokens from example to main app
Steps:
- Copy custom CSS animations from example:
# Source: _examples/onboarding/src/index.css
# Target: src/styles/onboarding.css (new file)
- Extract Meraki color palette to Tailwind config:
// tailwind.config.ts
module.exports = {
theme: {
extend: {
colors: {
'meraki-green': {
DEFAULT: 'hsl(169, 100%, 36%)', // #00B898
light: 'hsl(169, 100%, 42%)', // #00D4AA
},
},
boxShadow: {
'elegant': '0 10px 30px -10px hsl(169 100% 36% / 0.2)',
'lift': '0 20px 40px -15px hsl(169 100% 36% / 0.3)',
},
keyframes: {
breath: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.02)' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-4px)' },
},
'glow-pulse': {
'0%, 100%': { filter: 'drop-shadow(0 0 8px hsl(169 100% 42% / 0.4))' },
'50%': { filter: 'drop-shadow(0 0 16px hsl(169 100% 42% / 0.6))' },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-4px)' },
'75%': { transform: 'translateX(4px)' },
},
},
animation: {
'breath': 'breath 2s ease-in-out infinite',
'float': 'float 4s ease-in-out infinite',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'shake': 'shake 0.3s ease-in-out',
},
},
},
};
- Create utility classes file:
// src/lib/animations.ts
export const merakiGradient = 'bg-gradient-to-br from-meraki-green to-meraki-green-light';
Acceptance Criteria:
- ✅ All custom animations available as Tailwind classes
- ✅ Meraki color palette integrated
- ✅ Shadow system matches design spec
Files Created:
src/styles/onboarding.csssrc/lib/animations.ts
Files Modified:
tailwind.config.tssrc/app/globals.css(import onboarding.css)
Phase 2: Core Components (Days 3-5)
2.1 Component Structure Setup
Task: Create base component files and directory structure
Steps:
- Create directory structure:
mkdir -p src/components/onboarding/steps
mkdir -p src/hooks/api
mkdir -p src/types/onboarding
- Copy reusable components from example:
# Copy these as-is (they're design-system components):
# - RippleButton.tsx
# - StepCard.tsx
# - ProgressBar.tsx
- Create type definitions:
// src/types/onboarding.ts
export interface Organization {
organizationName: string;
organizationId: string;
overwiew: Array<{
productType: string;
count: number;
}>;
}
export interface Network {
id: string;
name: string;
organizationId: string;
apCount: number;
}
export interface DeviceInventory {
accessPoints: number;
switches: number;
sdwan: number;
cameras: number;
}
export interface OnboardingState {
currentStep: number;
apiKey: string;
selectedOrg: Organization | null;
selectedNetworks: string[];
}
Acceptance Criteria:
- ✅ Directory structure matches proposed architecture
- ✅ Type definitions cover all DTOs
- ✅ Reusable components compile without errors
Files Created:
src/components/onboarding/RippleButton.tsxsrc/components/onboarding/StepCard.tsxsrc/components/onboarding/ProgressBar.tsxsrc/types/onboarding.ts
2.2 OnboardingWizard Container
Task: Create main wizard container with state management
Steps:
- Create wizard component:
// src/components/onboarding/OnboardingWizard.tsx
'use client';
import { useState } from 'react';
import { ProgressBar } from './ProgressBar';
import { WelcomeStep } from './steps/WelcomeStep';
import { SelectTechStep } from './steps/SelectTechStep';
import { ApiKeyStep } from './steps/ApiKeyStep';
import { PickOrgStep } from './steps/PickOrgStep';
import { PickNetworksStep } from './steps/PickNetworksStep';
import { SummaryStep } from './steps/SummaryStep';
import { SuccessStep } from './steps/SuccessStep';
import type { OnboardingState } from '@/types/onboarding';
export const OnboardingWizard = () => {
const [currentStep, setCurrentStep] = useState(0);
const [state, setState] = useState<OnboardingState>({
currentStep: 0,
apiKey: '',
selectedOrg: null,
selectedNetworks: [],
});
const totalSteps = 6; // Not counting welcome (0) and success (7)
const nextStep = () => setCurrentStep(prev => prev + 1);
const updateState = (updates: Partial<OnboardingState>) => {
setState(prev => ({ ...prev, ...updates }));
};
return (
<div className="min-h-screen bg-background relative">
{currentStep > 0 && currentStep < 7 && (
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
)}
{currentStep === 0 && <WelcomeStep onNext={nextStep} />}
<div className="relative min-h-screen">
{currentStep === 1 && (
<SelectTechStep
isActive={true}
onNext={nextStep}
/>
)}
{currentStep === 2 && (
<ApiKeyStep
isActive={true}
onNext={nextStep}
onApiKeyValidated={(apiKey) => {
updateState({ apiKey });
nextStep();
}}
/>
)}
{currentStep === 3 && (
<PickOrgStep
isActive={true}
apiKey={state.apiKey}
onOrgSelected={(org) => {
updateState({ selectedOrg: org });
nextStep();
}}
/>
)}
{currentStep === 4 && (
<PickNetworksStep
isActive={true}
apiKey={state.apiKey}
organizationId={state.selectedOrg?.organizationId || ''}
onNetworksSelected={(networks) => {
updateState({ selectedNetworks: networks });
nextStep();
}}
/>
)}
{currentStep === 5 && (
<SummaryStep
isActive={true}
state={state}
onFinish={nextStep}
/>
)}
</div>
{currentStep === 6 && <SuccessStep isActive={true} />}
</div>
);
};
- Create page route:
// src/app/dashboard/onboarding/page.tsx
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
export default function OnboardingPage() {
return <OnboardingWizard />;
}
- Update changelog:
## [Unreleased] - 2025-10-29
### Added
- OnboardingWizard container component with state management
- Onboarding route at `/dashboard/onboarding`
Acceptance Criteria:
- ✅ Wizard renders with step 0 (Welcome)
- ✅ State persists between steps
- ✅ Navigation works via nextStep()
- ✅ Progress bar shows/hides correctly
Files Created:
src/components/onboarding/OnboardingWizard.tsxsrc/app/dashboard/onboarding/page.tsx
Files Modified:
changelog.MD
2.3 Reusable Components
Task: Implement shared UI components
2.3.1 RippleButton
Steps:
- Copy from example and adapt:
// src/components/onboarding/RippleButton.tsx
'use client';
import { forwardRef, useState } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
interface RippleButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'hero' | 'success';
size?: 'sm' | 'md' | 'lg';
}
export const RippleButton = forwardRef<HTMLButtonElement, RippleButtonProps>(
({ className, variant = 'default', size = 'md', onClick, children, ...props }, ref) => {
const [ripples, setRipples] = useState<Array<{ x: number; y: number; id: number }>>([]);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setRipples(prev => [...prev, { x, y, id: Date.now() }]);
setTimeout(() => {
setRipples(prev => prev.slice(1));
}, 600);
onClick?.(e);
};
const variantClasses = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
hero: 'bg-gradient-to-r from-meraki-green to-meraki-green-light text-white hover:shadow-lift',
success: 'bg-success text-success-foreground hover:bg-success/90',
};
const sizeClasses = {
sm: 'h-9 px-4 text-sm',
md: 'h-11 px-6 text-base',
lg: 'h-14 px-8 text-lg',
};
return (
<button
ref={ref}
className={cn(
'relative inline-flex items-center justify-center rounded-lg font-semibold transition-all overflow-hidden',
variantClasses[variant],
sizeClasses[size],
className
)}
onClick={handleClick}
{...props}
>
<span className="relative z-10">{children}</span>
{ripples.map(ripple => (
<span
key={ripple.id}
className="absolute bg-white/30 rounded-full animate-ripple"
style={{
left: ripple.x,
top: ripple.y,
width: 20,
height: 20,
transform: 'translate(-50%, -50%)',
}}
/>
))}
</button>
);
}
);
2.3.2 StepCard
Steps:
// src/components/onboarding/StepCard.tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import type { ReactNode } from 'react';
interface StepCardProps {
children: ReactNode;
isActive: boolean;
}
export const StepCard = ({ children, isActive }: StepCardProps) => {
return (
<AnimatePresence mode="wait">
{isActive && (
<motion.div
initial={{ x: '60%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-60%', opacity: 0 }}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
className="absolute inset-0 flex items-center justify-center p-8"
>
<div className="w-full max-w-2xl bg-card shadow-elegant rounded-2xl p-12">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
2.3.3 ProgressBar
Steps:
// src/components/onboarding/ProgressBar.tsx
'use client';
import { motion } from 'framer-motion';
interface ProgressBarProps {
currentStep: number;
totalSteps: number;
}
export const ProgressBar = ({ currentStep, totalSteps }: ProgressBarProps) => {
const progress = (currentStep / totalSteps) * 100;
return (
<div className="fixed top-0 left-0 right-0 z-50 h-1 bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-meraki-green to-meraki-green-light"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</div>
);
};
Acceptance Criteria:
- ✅ RippleButton creates ripple effect on click
- ✅ StepCard animates 60% slide
- ✅ ProgressBar shows liquid animation
Files Created:
src/components/onboarding/RippleButton.tsxsrc/components/onboarding/StepCard.tsxsrc/components/onboarding/ProgressBar.tsx
Phase 3: Step Components (Days 6-10)
3.1 Step 0: WelcomeStep
Task: Create welcome landing screen
Steps:
- Copy from example with minor adaptations:
// src/components/onboarding/steps/WelcomeStep.tsx
'use client';
import { motion } from 'framer-motion';
import { RippleButton } from '../RippleButton';
import { Sparkles } from 'lucide-react';
interface WelcomeStepProps {
onNext: () => void;
}
export const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
return (
<div className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-meraki-green/20 via-background to-meraki-green-light/20 animate-pulse" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative z-10 text-center space-y-8"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-meraki-green to-meraki-green-light animate-glow-pulse"
>
<Sparkles className="w-12 h-12 text-white" />
</motion.div>
<div className="space-y-4">
<h1 className="text-6xl font-bold tracking-tight">
Bienvenido a OnSite Behavior
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Configura tu integración con Cisco Meraki en solo unos minutos
</p>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<RippleButton
variant="hero"
size="lg"
onClick={onNext}
className="text-xl px-12 py-6"
>
Comenzar
</RippleButton>
</motion.div>
</motion.div>
</div>
);
};
Acceptance Criteria:
- ✅ Gradient background animates
- ✅ Icon has glow pulse effect
- ✅ Button click advances to step 1
Files Created:
src/components/onboarding/steps/WelcomeStep.tsx
3.2 Step 1: SelectTechStep
Task: Integration platform selection
Steps:
- Port from example with legacy data:
// src/components/onboarding/steps/SelectTechStep.tsx
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { RippleButton } from '../RippleButton';
import { StepCard } from '../StepCard';
import { Network, Cloud, Shield } from 'lucide-react';
interface SelectTechStepProps {
isActive: boolean;
onNext: () => void;
}
const techOptions = [
{
id: 'meraki',
name: 'Cisco Meraki',
description: 'Solución de redes administradas en la nube',
icon: Network,
available: true,
},
{
id: 'mist',
name: 'Juniper Mist AI',
description: 'Redes inalámbricas impulsadas por IA',
icon: Cloud,
available: false,
},
{
id: 'catalyst',
name: 'Cisco Catalyst',
description: 'Gestión de redes empresariales',
icon: Shield,
available: false,
},
];
export const SelectTechStep = ({ isActive, onNext }: SelectTechStepProps) => {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = (id: string, available: boolean) => {
if (available) {
setSelected(id);
}
};
return (
<StepCard isActive={isActive}>
<div className="space-y-8">
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Selecciona tu tecnología</h2>
<p className="text-muted-foreground text-lg">
Elige el proveedor de tu infraestructura de red
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{techOptions.map((tech, index) => {
const Icon = tech.icon;
const isSelected = selected === tech.id;
return (
<motion.button
key={tech.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleSelect(tech.id, tech.available)}
disabled={!tech.available}
className={`
relative p-6 rounded-xl border-2 transition-all duration-300
${
isSelected
? 'border-meraki-green shadow-lift bg-accent animate-breath'
: tech.available
? 'border-border hover:border-meraki-green hover:shadow-elegant hover:scale-105 bg-card'
: 'border-border bg-muted/50 opacity-40 cursor-not-allowed'
}
`}
>
<div className="flex flex-col items-center space-y-3">
<div
className={`
p-4 rounded-full transition-all
${
isSelected
? 'bg-meraki-green text-white animate-glow-pulse'
: 'bg-muted text-muted-foreground'
}
`}
>
<Icon className="h-8 w-8" />
</div>
<div className="space-y-1">
<span className="font-semibold text-lg block">{tech.name}</span>
<span className="text-sm text-muted-foreground block">
{tech.description}
</span>
</div>
{!tech.available && (
<span className="text-xs text-muted-foreground px-3 py-1 bg-muted rounded-full">
Próximamente
</span>
)}
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-success flex items-center justify-center"
>
<svg
className="w-4 h-4 text-success-foreground"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</motion.div>
)}
</motion.button>
);
})}
</div>
{selected && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-center pt-4"
>
<RippleButton variant="hero" size="lg" onClick={onNext}>
Continuar
</RippleButton>
</motion.div>
)}
</div>
</StepCard>
);
};
Acceptance Criteria:
- ✅ 3 cards render with correct data
- ✅ Only Meraki is selectable
- ✅ Selected card shows checkmark and breath animation
- ✅ Continue button appears after selection
Files Created:
src/components/onboarding/steps/SelectTechStep.tsx
3.3 Step 2: ApiKeyStep
Task: API key input and validation
Steps:
- Create API hook:
// src/hooks/api/useOnboardingApi.ts
import { useMutation } from '@tanstack/react-query';
interface ValidateApiKeyRequest {
apiKey: string;
}
interface ValidateApiKeyResponse {
result: boolean;
}
export const useValidateApiKey = () => {
return useMutation<ValidateApiKeyResponse, Error, ValidateApiKeyRequest>({
mutationFn: async ({ apiKey }) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/services/app/OnboardingService/ValidateApiKey`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ apiKey }),
});
if (!response.ok) {
throw new Error('API Key inválida');
}
return response.json();
},
});
};
- Create ApiKeyStep component:
// src/components/onboarding/steps/ApiKeyStep.tsx
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { RippleButton } from '../RippleButton';
import { StepCard } from '../StepCard';
import { Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useValidateApiKey } from '@/hooks/api/useOnboardingApi';
import { toast } from 'sonner';
interface ApiKeyStepProps {
isActive: boolean;
onNext: () => void;
onApiKeyValidated: (apiKey: string) => void;
}
export const ApiKeyStep = ({ isActive, onNext, onApiKeyValidated }: ApiKeyStepProps) => {
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [shake, setShake] = useState(false);
const minLength = 16; // Legacy uses 16, but Meraki standard is 40
const isLongEnough = apiKey.length >= minLength;
const { mutate: validateApiKey, isLoading, isSuccess, isError } = useValidateApiKey();
useEffect(() => {
if (isError) {
setShake(true);
setTimeout(() => setShake(false), 300);
toast.error('API Key inválida. Por favor verifica e intenta nuevamente.');
}
}, [isError]);
const handleValidate = async () => {
if (!isLongEnough) return;
validateApiKey({ apiKey }, {
onSuccess: () => {
onApiKeyValidated(apiKey);
setTimeout(onNext, 600);
},
});
};
const getStatusIcon = () => {
if (isLoading) {
return <Loader2 className="h-5 w-5 animate-spin text-warning" />;
}
if (isSuccess) {
return (
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>
<Check className="h-5 w-5 text-success" />
</motion.div>
);
}
if (isError) {
return <AlertCircle className="h-5 w-5 text-destructive" />;
}
return null;
};
const maskApiKey = (key: string) => {
if (showKey || key.length <= 8) return key;
return key.substring(0, 8) + '•'.repeat(Math.max(0, key.length - 8));
};
return (
<StepCard isActive={isActive}>
<div className={`space-y-8 ${shake ? 'animate-shake' : ''}`}>
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Configura tu API Key</h2>
<p className="text-muted-foreground text-lg">
Ingresa tu clave de API de Cisco Meraki
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key" className="text-base font-medium">
API Key
</Label>
<div className="relative">
<Input
id="api-key"
type="text"
value={maskApiKey(apiKey)}
onChange={(e) => setApiKey(e.target.value.replace(/•/g, ''))}
placeholder="Ingresa tu API key de Meraki..."
onKeyDown={(e) => e.key === 'Enter' && handleValidate()}
className={`
pr-20 text-base h-14 transition-all
${isSuccess ? 'border-success focus-visible:ring-success' : ''}
${isError ? 'border-destructive focus-visible:ring-destructive' : ''}
`}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{getStatusIcon()}
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{showKey ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
{!isLongEnough && apiKey.length > 0 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-destructive"
>
La API Key debe tener al menos {minLength} caracteres
</motion.p>
)}
</div>
<div className="flex items-center justify-center pt-4">
<RippleButton
variant={isLoading ? 'default' : isSuccess ? 'success' : 'hero'}
size="lg"
onClick={handleValidate}
disabled={!isLongEnough || isLoading}
className="min-w-[200px]"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Validando...
</>
) : isSuccess ? (
<>
<Check className="mr-2 h-5 w-5" />
Validado
</>
) : (
'Validar y Continuar'
)}
</RippleButton>
</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Puedes obtener tu API Key desde el{' '}
<a
href={process.env.NEXT_PUBLIC_MERAKI_DASHBOARD_URL}
target="_blank"
rel="noopener noreferrer"
className="text-meraki-green hover:underline"
>
Dashboard de Meraki
</a>
</p>
</div>
</div>
</div>
</StepCard>
);
};
Acceptance Criteria:
- ✅ API key input validates minimum length
- ✅ Show/hide toggle works
- ✅ Validation calls backend API
- ✅ Success shows checkmark and advances
- ✅ Error shows shake animation and toast
Files Created:
src/hooks/api/useOnboardingApi.tssrc/components/onboarding/steps/ApiKeyStep.tsx
Files Modified:
changelog.MD
3.4 Step 3: PickOrgStep
Task: Organization selection with device inventory
Steps:
- Add API hook:
// src/hooks/api/useOnboardingApi.ts (append)
import { useQuery } from '@tanstack/react-query';
import type { Organization } from '@/types/onboarding';
export const useGetOrganizations = (apiKey: string) => {
return useQuery<Organization[], Error>({
queryKey: ['organizations', apiKey],
queryFn: async () => {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/services/app/OnboardingService/GetOrganizations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ apiKey }),
});
if (!response.ok) {
throw new Error('Failed to fetch organizations');
}
const data = await response.json();
return data.result;
},
enabled: !!apiKey,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
- Create utility function:
// src/lib/onboarding-utils.ts
import type { DeviceInventory } from '@/types/onboarding';
const productMap: Record<string, keyof DeviceInventory> = {
switch: 'switches',
appliance: 'sdwan',
wireless: 'accessPoints',
camera: 'cameras',
};
export function parseOrganizationDevices(overview: Array<{ productType: string; count: number }>): DeviceInventory {
const sortedOverview = overview.sort((a, b) =>
a.productType.localeCompare(b.productType)
);
const inventory: DeviceInventory = {
accessPoints: 0,
switches: 0,
sdwan: 0,
cameras: 0,
};
sortedOverview.forEach(({ productType, count }) => {
const key = productMap[productType];
if (key) {
inventory[key] = count;
}
});
return inventory;
}
- Create PickOrgStep component:
// src/components/onboarding/steps/PickOrgStep.tsx
'use client';
import { motion } from 'framer-motion';
import { StepCard } from '../StepCard';
import { Building, Wifi, Server, Network, Camera, Loader2 } from 'lucide-react';
import { useGetOrganizations } from '@/hooks/api/useOnboardingApi';
import { parseOrganizationDevices } from '@/lib/onboarding-utils';
import type { Organization } from '@/types/onboarding';
interface PickOrgStepProps {
isActive: boolean;
apiKey: string;
onOrgSelected: (org: Organization) => void;
}
export const PickOrgStep = ({ isActive, apiKey, onOrgSelected }: PickOrgStepProps) => {
const { data: organizations, isLoading, isError } = useGetOrganizations(apiKey);
if (isLoading) {
return (
<StepCard isActive={isActive}>
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-meraki-green" />
<p className="text-muted-foreground">Cargando organizaciones...</p>
</div>
</StepCard>
);
}
if (isError) {
return (
<StepCard isActive={isActive}>
<div className="text-center space-y-4">
<p className="text-destructive">Error al cargar organizaciones</p>
</div>
</StepCard>
);
}
return (
<StepCard isActive={isActive}>
<div className="space-y-8">
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Selecciona tu organización</h2>
<p className="text-muted-foreground text-lg">
Elige la organización de Meraki que deseas integrar
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
{organizations?.map((org, index) => {
const inventory = parseOrganizationDevices(org.overwiew);
return (
<motion.button
key={org.organizationId}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => onOrgSelected(org)}
className="p-6 rounded-xl border-2 border-border hover:border-meraki-green hover:shadow-elegant transition-all duration-300 bg-card text-left"
>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="p-3 rounded-full bg-meraki-green/10">
<Building className="h-6 w-6 text-meraki-green" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">
{org.organizationName}
</h3>
<p className="text-sm text-muted-foreground">
ID: {org.organizationId}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-meraki-green" />
<span className="text-sm">{inventory.accessPoints} APs</span>
</div>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-meraki-green" />
<span className="text-sm">{inventory.switches} Switches</span>
</div>
<div className="flex items-center gap-2">
<Network className="h-4 w-4 text-meraki-green" />
<span className="text-sm">{inventory.sdwan} SD-WAN</span>
</div>
<div className="flex items-center gap-2">
<Camera className="h-4 w-4 text-meraki-green" />
<span className="text-sm">{inventory.cameras} Cámaras</span>
</div>
</div>
</div>
</motion.button>
);
})}
</div>
</div>
</StepCard>
);
};
Acceptance Criteria:
- ✅ Organizations fetch from API with caching
- ✅ Device inventory displays correctly
- ✅ Cards have hover effects
- ✅ Clicking card advances to networks step
Files Created:
src/lib/onboarding-utils.tssrc/components/onboarding/steps/PickOrgStep.tsx
Files Modified:
src/hooks/api/useOnboardingApi.ts
3.5 Step 4: PickNetworksStep
Task: Multi-select networks with AP counts
Steps:
- Add API hook:
// src/hooks/api/useOnboardingApi.ts (append)
import type { Network } from '@/types/onboarding';
export const useGetOrganizationNetworks = (apiKey: string, organizationId: string) => {
return useQuery<Network[], Error>({
queryKey: ['networks', apiKey, organizationId],
queryFn: async () => {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/services/app/OnboardingService/GetOrganizationsNetworks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ apiKey, organizationId }),
});
if (!response.ok) {
throw new Error('Failed to fetch networks');
}
const data = await response.json();
return data.result;
},
enabled: !!apiKey && !!organizationId,
staleTime: 5 * 60 * 1000,
});
};
- Create PickNetworksStep component:
// src/components/onboarding/steps/PickNetworksStep.tsx
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { RippleButton } from '../RippleButton';
import { StepCard } from '../StepCard';
import { Wifi, Check, Loader2 } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useGetOrganizationNetworks } from '@/hooks/api/useOnboardingApi';
interface PickNetworksStepProps {
isActive: boolean;
apiKey: string;
organizationId: string;
onNetworksSelected: (networkIds: string[]) => void;
}
export const PickNetworksStep = ({
isActive,
apiKey,
organizationId,
onNetworksSelected,
}: PickNetworksStepProps) => {
const [selectedNetworks, setSelectedNetworks] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
const { data: networks, isLoading } = useGetOrganizationNetworks(apiKey, organizationId);
const toggleNetwork = (id: string) => {
setSelectedNetworks(prev =>
prev.includes(id) ? prev.filter(n => n !== id) : [...prev, id]
);
};
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setSelectedNetworks(checked ? networks?.map(n => n.id) || [] : []);
};
const handleContinue = () => {
onNetworksSelected(selectedNetworks);
};
if (isLoading) {
return (
<StepCard isActive={isActive}>
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-meraki-green" />
<p className="text-muted-foreground">Cargando redes...</p>
</div>
</StepCard>
);
}
return (
<StepCard isActive={isActive}>
<div className="space-y-6">
<div className="sticky top-0 bg-card z-10 pb-4 border-b space-y-4">
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Selecciona las redes</h2>
<p className="text-muted-foreground text-lg">
Elige las redes que deseas monitorear
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
id="select-all"
checked={selectAll}
onCheckedChange={handleSelectAll}
/>
<Label htmlFor="select-all" className="font-medium cursor-pointer">
Seleccionar todo
</Label>
</div>
<RippleButton
variant="ghost"
size="sm"
onClick={() => {
setSelectedNetworks([]);
setSelectAll(false);
}}
>
Limpiar selección
</RippleButton>
</div>
</div>
<div className="space-y-3 max-h-80 overflow-y-auto pr-2">
{networks?.map((network, index) => {
const isSelected = selectedNetworks.includes(network.id);
return (
<motion.div
key={network.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className={`
border-2 rounded-xl overflow-hidden transition-all duration-300 cursor-pointer
${
isSelected
? 'border-meraki-green shadow-elegant bg-accent'
: 'border-border hover:border-meraki-green/50'
}
`}
onClick={() => toggleNetwork(network.id)}
>
<div className="p-4 flex items-center gap-3">
<div
className={`
w-6 h-6 rounded border-2 flex items-center justify-center
transition-all duration-200
${
isSelected
? 'border-meraki-green bg-meraki-green'
: 'border-muted-foreground'
}
`}
>
{isSelected && (
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>
<Check className="h-4 w-4 text-white" />
</motion.div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base truncate">
{network.name}
</h3>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Wifi className="h-3 w-3" />
<span>{network.apCount} Puntos de Acceso</span>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
{selectedNetworks.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-20"
>
<div className="bg-gradient-to-r from-meraki-green to-meraki-green-light shadow-lift rounded-full px-6 py-3 flex items-center gap-4">
<span className="text-white font-medium">
{selectedNetworks.length} red{selectedNetworks.length !== 1 ? 'es' : ''}{' '}
seleccionada{selectedNetworks.length !== 1 ? 's' : ''}
</span>
<RippleButton
variant="success"
size="sm"
onClick={handleContinue}
className="bg-white text-meraki-green hover:bg-white/90"
>
Continuar
</RippleButton>
</div>
</motion.div>
)}
</div>
</StepCard>
);
};
Acceptance Criteria:
- ✅ Networks load from API
- ✅ Multi-select with checkboxes
- ✅ Select All / Clear Selection works
- ✅ Floating continue button shows selected count
- ✅ Selected cards have green border and animation
Files Created:
src/components/onboarding/steps/PickNetworksStep.tsx
Files Modified:
src/hooks/api/useOnboardingApi.ts
3.6 Step 5: SummaryStep
Task: Review configuration and finish setup
Steps:
- Add API hook:
// src/hooks/api/useOnboardingApi.ts (append)
interface FinishSetupRequest {
organizationId: string;
networks: string[];
apiKey: string;
}
interface FinishSetupResponse {
success: boolean;
}
export const useFinishSetup = () => {
return useMutation<FinishSetupResponse, Error, FinishSetupRequest>({
mutationFn: async (data) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/services/app/OnboardingService/FinishSetup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to finish setup');
}
return response.json();
},
});
};
- Add utility function:
// src/lib/onboarding-utils.ts (append)
export function getLicenseBracket(apCount: number): string {
if (apCount <= 10) return 'Básico (hasta 10 APs)';
if (apCount <= 25) return 'Estándar (hasta 25 APs)';
if (apCount <= 50) return 'Plus (hasta 50 APs)';
if (apCount <= 100) return 'Premium (hasta 100 APs)';
return 'Empresarial (100+ APs)';
}
- Create SummaryStep component:
// src/components/onboarding/steps/SummaryStep.tsx
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { RippleButton } from '../RippleButton';
import { StepCard } from '../StepCard';
import { Building, Network as NetworkIcon, Wifi, Shield, Loader2, ArrowLeft } from 'lucide-react';
import { useGetOrganizationNetworks, useFinishSetup } from '@/hooks/api/useOnboardingApi';
import { getLicenseBracket } from '@/lib/onboarding-utils';
import { toast } from 'sonner';
import type { OnboardingState } from '@/types/onboarding';
interface SummaryStepProps {
isActive: boolean;
state: OnboardingState;
onFinish: () => void;
onBack?: () => void;
}
export const SummaryStep = ({ isActive, state, onFinish, onBack }: SummaryStepProps) => {
const { data: allNetworks } = useGetOrganizationNetworks(
state.apiKey,
state.selectedOrg?.organizationId || ''
);
const { mutate: finishSetup, isLoading } = useFinishSetup();
const selectedNetworkObjects = useMemo(() => {
return allNetworks?.filter(network =>
state.selectedNetworks.includes(network.id)
) || [];
}, [allNetworks, state.selectedNetworks]);
const totalAPs = useMemo(() => {
return selectedNetworkObjects.reduce((sum, network) => sum + network.apCount, 0);
}, [selectedNetworkObjects]);
const licenseBracket = useMemo(() => {
return getLicenseBracket(totalAPs);
}, [totalAPs]);
const handleFinish = () => {
if (!state.selectedOrg) return;
finishSetup(
{
organizationId: state.selectedOrg.organizationId,
networks: state.selectedNetworks,
apiKey: state.apiKey,
},
{
onSuccess: () => {
toast.success('¡Configuración completada exitosamente!');
setTimeout(onFinish, 600);
},
onError: () => {
toast.error('Error al finalizar la configuración. Por favor intenta nuevamente.');
},
}
);
};
return (
<StepCard isActive={isActive}>
<div className="space-y-8">
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Resumen de configuración</h2>
<p className="text-muted-foreground text-lg">
Revisa tu configuración antes de finalizar
</p>
</div>
<div className="space-y-6">
{/* Organization Info */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-6 rounded-xl bg-accent border-2 border-meraki-green/20"
>
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-full bg-meraki-green/10">
<Building className="h-6 w-6 text-meraki-green" />
</div>
<div>
<h3 className="font-semibold text-lg">Organización</h3>
<p className="text-sm text-muted-foreground">
{state.selectedOrg?.organizationName}
</p>
</div>
</div>
</motion.div>
{/* Stats Grid */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-3 gap-4"
>
<div className="p-4 rounded-xl bg-card border-2 border-border text-center">
<NetworkIcon className="h-6 w-6 text-meraki-green mx-auto mb-2" />
<div className="text-2xl font-bold">{state.selectedNetworks.length}</div>
<div className="text-sm text-muted-foreground">Redes</div>
</div>
<div className="p-4 rounded-xl bg-card border-2 border-border text-center">
<Wifi className="h-6 w-6 text-meraki-green mx-auto mb-2" />
<div className="text-2xl font-bold">{totalAPs}</div>
<div className="text-sm text-muted-foreground">Total APs</div>
</div>
<div className="p-4 rounded-xl bg-card border-2 border-border text-center">
<Shield className="h-6 w-6 text-meraki-green mx-auto mb-2" />
<div className="text-xs font-semibold text-meraki-green">LICENCIA</div>
<div className="text-xs text-muted-foreground mt-1">{licenseBracket}</div>
</div>
</motion.div>
{/* Network List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-2"
>
<h3 className="font-semibold">Redes seleccionadas</h3>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedNetworkObjects.map((network, index) => (
<motion.div
key={network.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + index * 0.05 }}
className="flex items-center justify-between p-3 rounded-lg bg-muted"
>
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-meraki-green" />
<span className="font-medium">{network.name}</span>
</div>
<span className="text-sm text-muted-foreground">
{network.apCount} APs
</span>
</motion.div>
))}
</div>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex gap-4 justify-center pt-4"
>
{onBack && (
<RippleButton
variant="default"
size="lg"
onClick={onBack}
disabled={isLoading}
>
<ArrowLeft className="mr-2 h-5 w-5" />
Atrás
</RippleButton>
)}
<RippleButton
variant="hero"
size="lg"
onClick={handleFinish}
disabled={isLoading}
className="min-w-[250px]"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Finalizando...
</>
) : (
'Finalizar Configuración'
)}
</RippleButton>
</motion.div>
</div>
</StepCard>
);
};
Acceptance Criteria:
- ✅ Summary displays all selected data
- ✅ License bracket calculates correctly
- ✅ Network list shows with AP counts
- ✅ Finish button calls backend API
- ✅ Success toast shows and advances to success screen
Files Created:
src/components/onboarding/steps/SummaryStep.tsx
Files Modified:
src/hooks/api/useOnboardingApi.tssrc/lib/onboarding-utils.ts
3.7 Step 6: SuccessStep
Task: Celebration screen with auto-redirect
Steps:
- Create SuccessStep component:
// src/components/onboarding/steps/SuccessStep.tsx
'use client';
import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { CheckCircle2, Sparkles } from 'lucide-react';
import confetti from 'canvas-confetti';
interface SuccessStepProps {
isActive: boolean;
}
export const SuccessStep = ({ isActive }: SuccessStepProps) => {
const router = useRouter();
useEffect(() => {
if (isActive) {
// Trigger confetti
const duration = 3 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
const interval: any = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
});
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
});
}, 250);
// Auto-redirect after 3 seconds
const redirectTimeout = setTimeout(() => {
router.push('/dashboard?id=1');
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(redirectTimeout);
};
}
}, [isActive, router]);
return (
<div className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-meraki-green/10 via-background to-success/10" />
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="relative z-10 text-center space-y-8 max-w-2xl"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="inline-flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-meraki-green to-success animate-glow-pulse"
>
<CheckCircle2 className="w-16 h-16 text-white" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<h1 className="text-6xl font-bold tracking-tight">
¡Configuración Completada!
</h1>
<p className="text-xl text-muted-foreground">
Tu integración con Cisco Meraki ha sido configurada exitosamente
</p>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex items-center justify-center gap-2 text-muted-foreground"
>
<Sparkles className="h-5 w-5 text-meraki-green animate-pulse" />
<p>Redirigiendo al dashboard...</p>
</motion.div>
</motion.div>
</div>
);
};
Acceptance Criteria:
- ✅ Confetti animation triggers on mount
- ✅ Success message displays with animations
- ✅ Auto-redirect to dashboard after 3 seconds
- ✅ Cleanup prevents memory leaks
Files Created:
src/components/onboarding/steps/SuccessStep.tsx
Phase 4: Integration & Testing (Days 11-13)
4.1 Wire Up All Steps
Task: Connect all steps in OnboardingWizard with proper state flow
Steps:
- Update OnboardingWizard with complete navigation:
// src/components/onboarding/OnboardingWizard.tsx (update)
const goBack = () => setCurrentStep(prev => Math.max(0, prev - 1));
- Pass back handlers to relevant steps:
{currentStep === 5 && (
<SummaryStep
isActive={true}
state={state}
onFinish={nextStep}
onBack={goBack}
/>
)}
- Test full flow manually:
- Step 0: Welcome → Click "Comenzar"
- Step 1: Select Meraki → Click "Continuar"
- Step 2: Enter valid API key → Click "Validar y Continuar"
- Step 3: Select organization → Auto-advance
- Step 4: Select networks → Click "Continuar"
- Step 5: Review summary → Click "Finalizar"
- Step 6: Success → Auto-redirect
Acceptance Criteria:
- ✅ All steps navigate correctly
- ✅ State persists across steps
- ✅ Back navigation works (where applicable)
- ✅ No console errors
Files Modified:
src/components/onboarding/OnboardingWizard.tsx
4.2 Error Handling & Edge Cases
Task: Implement comprehensive error handling
Steps:
- Add error boundaries:
// src/components/onboarding/OnboardingErrorBoundary.tsx
'use client';
import { Component, type ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
import { RippleButton } from './RippleButton';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class OnboardingErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="text-center space-y-6">
<AlertCircle className="h-16 w-16 text-destructive mx-auto" />
<div>
<h2 className="text-2xl font-bold mb-2">Algo salió mal</h2>
<p className="text-muted-foreground">
Ocurrió un error durante el proceso de configuración
</p>
</div>
<RippleButton
variant="hero"
onClick={() => window.location.href = '/dashboard/onboarding'}
>
Reintentar
</RippleButton>
</div>
</div>
);
}
return this.props.children;
}
}
- Wrap OnboardingWizard:
// src/app/dashboard/onboarding/page.tsx (update)
import { OnboardingErrorBoundary } from '@/components/onboarding/OnboardingErrorBoundary';
export default function OnboardingPage() {
return (
<OnboardingErrorBoundary>
<OnboardingWizard />
</OnboardingErrorBoundary>
);
}
- Add API error handling:
// src/hooks/api/useOnboardingApi.ts (update all hooks)
onError: (error) => {
console.error('API Error:', error);
toast.error(error.message || 'Ocurrió un error inesperado');
}
- Handle edge cases:
- No organizations found
- No wireless networks
- API timeout
- Network failure
Acceptance Criteria:
- ✅ Error boundary catches React errors
- ✅ API errors show user-friendly toasts
- ✅ Empty states display helpful messages
- ✅ Network failures handled gracefully
Files Created:
src/components/onboarding/OnboardingErrorBoundary.tsx
Files Modified:
src/app/dashboard/onboarding/page.tsxsrc/hooks/api/useOnboardingApi.ts
4.3 Loading States & Skeletons
Task: Add loading skeletons for better UX
Steps:
- Create skeleton components:
// src/components/onboarding/OrganizationSkeleton.tsx
export const OrganizationSkeleton = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="p-6 rounded-xl border-2 border-border animate-pulse">
<div className="flex items-start gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map(j => (
<div key={j} className="h-6 bg-muted rounded" />
))}
</div>
</div>
))}
</div>
);
};
- Use in PickOrgStep:
import { OrganizationSkeleton } from '../OrganizationSkeleton';
if (isLoading) {
return (
<StepCard isActive={isActive}>
<div className="space-y-8">
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Selecciona tu organización</h2>
<p className="text-muted-foreground text-lg">
Cargando organizaciones...
</p>
</div>
<OrganizationSkeleton />
</div>
</StepCard>
);
}
- Create similar skeletons for networks
Acceptance Criteria:
- ✅ Skeletons match final component layout
- ✅ No layout shift when data loads
- ✅ Animations smooth and professional
Files Created:
src/components/onboarding/OrganizationSkeleton.tsxsrc/components/onboarding/NetworkSkeleton.tsx
4.4 Accessibility (a11y)
Task: Ensure WCAG 2.1 AA compliance
Steps:
- Add keyboard navigation:
// Listen for Escape key to go back
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && currentStep > 0 && currentStep < 7) {
goBack();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentStep]);
- Add ARIA labels:
<motion.button
aria-label={`Select ${tech.name}`}
aria-pressed={isSelected}
role="button"
tabIndex={0}
// ...
>
- Add focus management:
import { useEffect, useRef } from 'react';
const firstFocusableRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isActive) {
firstFocusableRef.current?.focus();
}
}, [isActive]);
- Test with screen reader (NVDA or VoiceOver)
Acceptance Criteria:
- ✅ All interactive elements keyboard accessible
- ✅ Focus visible on all elements
- ✅ ARIA labels descriptive
- ✅ Tab order logical
- ✅ Screen reader announces step changes
Files Modified:
- All step components
src/components/onboarding/OnboardingWizard.tsx
4.5 Responsive Design
Task: Ensure mobile/tablet compatibility
Steps:
-
Test on mobile viewport (375px):
- Welcome screen
- Tech selection (single column)
- API key input (full width)
- Organization cards (single column)
- Network list (single column)
- Summary (responsive grid)
-
Adjust StepCard padding:
<div className="w-full max-w-2xl bg-card shadow-elegant rounded-2xl p-6 md:p-12">
- Update PickNetworksStep floating button:
className="fixed bottom-8 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-auto z-20"
- Test on tablet (768px):
- 2-column grids
Acceptance Criteria:
- ✅ All steps render correctly on mobile (375px)
- ✅ Touch targets minimum 44px
- ✅ Text readable without zoom
- ✅ No horizontal scroll
Files Modified:
- All step components
src/components/onboarding/StepCard.tsx
Phase 5: Optimization & Polish (Days 14-15)
5.1 Performance Optimization
Task: Optimize bundle size and runtime performance
Steps:
- Analyze bundle:
npm run build
npx @next/bundle-analyzer
- Code split heavy components:
import dynamic from 'next/dynamic';
const OnboardingWizard = dynamic(
() => import('@/components/onboarding/OnboardingWizard'),
{
loading: () => (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-meraki-green" />
</div>
),
}
);
- Prefetch data:
// In ApiKeyStep, after validation success
await queryClient.prefetchQuery({
queryKey: ['organizations', apiKey],
queryFn: () => fetchOrganizations(apiKey),
});
-
Optimize images:
- Use Next.js Image component
- Add proper width/height
- Use WebP format
-
Add React.memo where appropriate:
export const StepCard = React.memo(({ children, isActive }: StepCardProps) => {
// ...
});
Acceptance Criteria:
- ✅ First Contentful Paint < 1.5s
- ✅ Time to Interactive < 3s
- ✅ Bundle size < 500KB (gzipped)
- ✅ Lighthouse score > 90
Files Modified:
- Various components for memoization
src/app/dashboard/onboarding/page.tsx
5.2 Animation Polish
Task: Fine-tune animations for production
Steps:
- Reduce animation duration in production:
const ANIMATION_DURATION = process.env.NODE_ENV === 'development' ? 400 : 300;
<motion.div
transition={{ duration: ANIMATION_DURATION / 1000 }}
>
- Add prefers-reduced-motion support:
// src/lib/motion-utils.ts
export const useReducedMotion = () => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => {
setPrefersReducedMotion(e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return prefersReducedMotion;
};
// Usage in components
const prefersReducedMotion = useReducedMotion();
<motion.div
initial={prefersReducedMotion ? {} : { opacity: 0, y: 20 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
>
- Test all animations at 60fps:
- Use Chrome DevTools Performance tab
- Check for jank during transitions
Acceptance Criteria:
- ✅ All animations 60fps
- ✅ Reduced motion respected
- ✅ No layout thrashing
- ✅ GPU acceleration used where beneficial
Files Created:
src/lib/motion-utils.ts
Files Modified:
- All step components
5.3 Testing
Task: Write comprehensive tests
5.3.1 Unit Tests
Steps:
- Install testing dependencies:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
- Create test file:
// src/lib/onboarding-utils.test.ts
import { describe, it, expect } from 'vitest';
import { parseOrganizationDevices, getLicenseBracket } from './onboarding-utils';
describe('parseOrganizationDevices', () => {
it('should correctly map device types', () => {
const overview = [
{ productType: 'wireless', count: 10 },
{ productType: 'switch', count: 5 },
];
const result = parseOrganizationDevices(overview);
expect(result).toEqual({
accessPoints: 10,
switches: 5,
sdwan: 0,
cameras: 0,
});
});
});
describe('getLicenseBracket', () => {
it('should return correct bracket for AP counts', () => {
expect(getLicenseBracket(5)).toBe('Básico (hasta 10 APs)');
expect(getLicenseBracket(15)).toBe('Estándar (hasta 25 APs)');
expect(getLicenseBracket(150)).toBe('Empresarial (100+ APs)');
});
});
- Add component tests:
// src/components/onboarding/ProgressBar.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { ProgressBar } from './ProgressBar';
describe('ProgressBar', () => {
it('should render with correct progress', () => {
const { container } = render(
<ProgressBar currentStep={3} totalSteps={6} />
);
const progressBar = container.querySelector('.h-full');
expect(progressBar).toHaveStyle({ width: '50%' });
});
});
Acceptance Criteria:
- ✅ All utility functions tested
- ✅ Component rendering tests
- ✅ Test coverage > 80%
5.3.2 Integration Tests
Steps:
- Create API mocks:
// src/tests/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.post('/api/services/app/OnboardingService/ValidateApiKey', (req, res, ctx) => {
return res(ctx.json({ result: true }));
}),
rest.post('/api/services/app/OnboardingService/GetOrganizations', (req, res, ctx) => {
return res(ctx.json({
result: [
{
organizationName: 'Test Org',
organizationId: '12345',
overwiew: [
{ productType: 'wireless', count: 10 }
]
}
]
}));
}),
// Add more handlers...
];
- Create integration test:
// src/components/onboarding/OnboardingWizard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OnboardingWizard } from './OnboardingWizard';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('OnboardingWizard Integration', () => {
it('should complete full onboarding flow', async () => {
const user = userEvent.setup();
render(<OnboardingWizard />, { wrapper: createWrapper() });
// Step 0: Welcome
expect(screen.getByText('Bienvenido a SplashPage')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /comenzar/i }));
// Step 1: Tech Selection
await waitFor(() => {
expect(screen.getByText('Selecciona tu tecnología')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /cisco meraki/i }));
await user.click(screen.getByRole('button', { name: /continuar/i }));
// Step 2: API Key
await waitFor(() => {
expect(screen.getByLabelText('API Key')).toBeInTheDocument();
});
await user.type(screen.getByLabelText('API Key'), 'validApiKey123456');
await user.click(screen.getByRole('button', { name: /validar/i }));
// Add more assertions...
});
});
Acceptance Criteria:
- ✅ Full flow test passes
- ✅ API mocks working correctly
- ✅ State transitions verified
5.4 Documentation
Task: Create user and developer documentation
Steps:
- Update changelog:
## [1.0.0] - 2025-10-29
### Added
- Complete Next.js onboarding module with modern design
- 7-step wizard with animations and transitions
- API integration with Meraki Dashboard API
- Automatic dashboard and captive portal creation
- Success screen with confetti animation
### Changed
- Migrated from ASP.NET MVC to Next.js 14
- Updated design system with Meraki branding
- Improved UX with loading states and error handling
### Technical Details
- Framework: Next.js 14 App Router
- UI: shadcn/ui + Tailwind CSS
- Animations: Framer Motion
- State: TanStack Query
- Forms: React Hook Form + Zod
- Create developer documentation:
<!-- docs/onboarding-module.md -->
# Onboarding Module
## Overview
The onboarding module guides new users through Cisco Meraki integration setup.
## Architecture
See `onboarding_arquitecture.md` for detailed architecture.
## API Endpoints
- `POST /api/services/app/OnboardingService/ValidateApiKey`
- `POST /api/services/app/OnboardingService/GetOrganizations`
- `POST /api/services/app/OnboardingService/GetOrganizationsNetworks`
- `POST /api/services/app/OnboardingService/FinishSetup`
## Local Development
```bash
cd src/SplashPage.Web.Ui
npm run dev
Navigate to http://localhost:3000/dashboard/onboarding
Testing
npm test
Known Issues
- API key validation requires minimum 16 characters (Meraki standard is 40)
3. Add inline code documentation:
```typescript
/**
* OnboardingWizard Component
*
* Main container for the 7-step onboarding flow.
* Manages global state and step navigation.
*
* @example
* ```tsx
* <OnboardingWizard />
* ```
*/
export const OnboardingWizard = () => {
// ...
};
Acceptance Criteria:
- ✅ Changelog updated with all changes
- ✅ Developer documentation complete
- ✅ Inline documentation added to public APIs
Files Created:
docs/onboarding-module.md
Files Modified:
changelog.MD- All public component files
Phase 6: Deployment (Day 16)
6.1 Production Build
Task: Prepare for production deployment
Steps:
- Create production build:
cd src/SplashPage.Web.Ui
npm run build
- Test production build locally:
npm start
-
Verify all features work in production mode
-
Check bundle analysis:
ANALYZE=true npm run build
Acceptance Criteria:
- ✅ Build completes without errors
- ✅ Production bundle optimized
- ✅ All features work in production
6.2 Environment Configuration
Task: Set up production environment variables
Steps:
- Create
.env.production:
NEXT_PUBLIC_API_URL=https://api.splashpage.com
NEXT_PUBLIC_MERAKI_DASHBOARD_URL=https://dashboard.meraki.com
- Add to deployment pipeline:
# .github/workflows/deploy.yml
- name: Set environment variables
run: |
echo "NEXT_PUBLIC_API_URL=${{ secrets.API_URL }}" >> .env.production
- Document required environment variables
Acceptance Criteria:
- ✅ Environment variables configured
- ✅ Secrets stored securely
- ✅ Documentation updated
6.3 Legacy Route Migration
Task: Set up redirect from old MVC route to Next.js
Steps:
- Update Nginx configuration:
# /etc/nginx/sites-available/splashpage.conf
# Redirect old MVC onboarding to Next.js
location /Onboarding {
return 301 /dashboard/onboarding;
}
# Proxy Next.js onboarding
location /dashboard/onboarding {
proxy_pass http://nextjs:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
- Test redirect:
curl -I https://splashpage.com/Onboarding
# Should return 301 with Location: /dashboard/onboarding
- Update internal links:
// Replace all instances of /Onboarding with /dashboard/onboarding
Acceptance Criteria:
- ✅ Old route redirects to new route
- ✅ Internal links updated
- ✅ No broken links
Files Modified:
- Nginx configuration
- Various internal link references
6.4 Monitoring & Analytics
Task: Set up production monitoring
Steps:
- Add error tracking (Sentry):
// src/app/layout.tsx
import * as Sentry from '@sentry/nextjs';
if (process.env.NODE_ENV === 'production') {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: 'production',
});
}
- Add analytics events:
// src/lib/analytics.ts
export const trackEvent = (event: string, properties?: Record<string, any>) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', event, properties);
}
};
// Usage
trackEvent('onboarding_step_completed', {
step: currentStep,
stepName: 'api_key_validation',
});
- Add performance monitoring:
// src/components/onboarding/OnboardingWizard.tsx
import { useEffect } from 'react';
useEffect(() => {
const startTime = performance.now();
return () => {
const duration = performance.now() - startTime;
trackEvent('onboarding_step_duration', {
step: currentStep,
duration,
});
};
}, [currentStep]);
Acceptance Criteria:
- ✅ Error tracking configured
- ✅ Analytics events firing
- ✅ Performance metrics collected
Files Created:
src/lib/analytics.ts
Files Modified:
src/app/layout.tsxsrc/components/onboarding/OnboardingWizard.tsx
6.5 Go Live
Task: Deploy to production
Steps:
-
Run final checklist:
- All tests passing
- Production build successful
- Environment variables configured
- Documentation complete
- Monitoring enabled
- Redirect configured
-
Deploy Next.js app:
# Via CI/CD pipeline or manual deployment
docker build -t splashpage-ui:latest .
docker push splashpage-ui:latest
kubectl apply -f k8s/deployment.yml
-
Verify deployment:
- Navigate to https://splashpage.com/dashboard/onboarding
- Complete full onboarding flow
- Check error tracking dashboard
- Monitor analytics
-
Monitor for 24 hours:
- Watch error rates
- Check user completion rates
- Review performance metrics
Acceptance Criteria:
- ✅ Deployment successful
- ✅ No critical errors in production
- ✅ User feedback positive
- ✅ Metrics within expected ranges
Post-Launch (Days 17-20)
Maintenance Tasks
-
Monitor Performance
- Review Lighthouse scores weekly
- Optimize slow queries
- Update dependencies monthly
-
User Feedback
- Collect user feedback via surveys
- Implement UX improvements
- Track completion rates
-
Backend Optimization
- Review Meraki API call patterns
- Optimize caching strategy
- Reduce database queries
-
Feature Enhancements
- Add dark mode support
- Implement save/resume functionality
- Add multi-language support
Success Metrics
Technical Metrics
- Performance: Lighthouse score > 90
- Reliability: Error rate < 0.5%
- Availability: Uptime > 99.9%
- Bundle Size: < 500KB gzipped
Business Metrics
- Completion Rate: > 80% of users complete onboarding
- Time to Complete: Average < 5 minutes
- User Satisfaction: NPS > 8
Risk Management
Potential Issues & Mitigation
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| API rate limiting | High | Medium | Implement aggressive caching + backoff |
| Network timeouts | Medium | High | Add retry logic + user feedback |
| Browser compatibility | Low | Low | Test on Chrome, Firefox, Safari, Edge |
| Data validation errors | Medium | Medium | Comprehensive form validation + tests |
| Memory leaks | Medium | Low | Proper cleanup in useEffect hooks |
Rollback Plan
If critical issues arise post-deployment:
- Immediate: Redirect
/dashboard/onboarding→/Onboarding(legacy MVC) - Within 1 hour: Disable Next.js route in Nginx
- Within 24 hours: Fix critical bugs and redeploy
- Within 1 week: Address user feedback and optimize
Appendix
Useful Commands
# Development
npm run dev # Start dev server
npm run build # Production build
npm run start # Start production server
npm test # Run tests
npm run lint # Lint code
# Deployment
docker build -t splashpage-ui .
docker run -p 3000:3000 splashpage-ui
# Debugging
npm run build -- --profile # Profile build
ANALYZE=true npm run build # Analyze bundle
Key File Locations
src/SplashPage.Web.Ui/
├── src/
│ ├── app/
│ │ └── dashboard/
│ │ └── onboarding/
│ │ └── page.tsx
│ ├── components/
│ │ └── onboarding/
│ │ ├── OnboardingWizard.tsx
│ │ ├── ProgressBar.tsx
│ │ ├── StepCard.tsx
│ │ ├── RippleButton.tsx
│ │ └── steps/
│ ├── hooks/
│ │ └── api/
│ │ └── useOnboardingApi.ts
│ ├── lib/
│ │ ├── onboarding-utils.ts
│ │ └── analytics.ts
│ └── types/
│ └── onboarding.ts
└── tailwind.config.ts
Contact & Support
- Developer: [Your Name]
- PM: [PM Name]
- Support: support@splashpage.com
- Documentation: https://docs.splashpage.com/onboarding
End of Action Plan