Files
Temp_MSSPLASHPage/onboarding_plan.md

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:

  1. Verify Next.js app is running: cd src/SplashPage.Web.Ui && npm run dev
  2. Install missing dependencies (if any):
npm install @tanstack/react-query framer-motion canvas-confetti
npm install -D @types/node
  1. 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:

  1. Verify Swagger endpoint includes onboarding services:
curl https://localhost:44311/swagger/v1/swagger.json | grep "OnboardingService"
  1. If not present, regenerate API client:
npm run kubb:generate
  1. Verify generated types exist:
// Should be auto-generated by Kubb
import {
  useOnboardingServiceValidateApiKey,
  useOnboardingServiceGetOrganizations,
  useOnboardingServiceGetOrganizationsNetworks,
  useOnboardingServiceFinishSetup,
} from '@/lib/api';
  1. 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:

  1. Check if QueryClientProvider exists in app layout:
// src/app/layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  1. 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>
  );
}
  1. 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.tsx or src/app/providers.tsx

1.4 Design System Adaptation

Task: Port design tokens from example to main app

Steps:

  1. Copy custom CSS animations from example:
# Source: _examples/onboarding/src/index.css
# Target: src/styles/onboarding.css (new file)
  1. 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',
      },
    },
  },
};
  1. 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.css
  • src/lib/animations.ts

Files Modified:

  • tailwind.config.ts
  • src/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:

  1. Create directory structure:
mkdir -p src/components/onboarding/steps
mkdir -p src/hooks/api
mkdir -p src/types/onboarding
  1. Copy reusable components from example:
# Copy these as-is (they're design-system components):
# - RippleButton.tsx
# - StepCard.tsx
# - ProgressBar.tsx
  1. 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.tsx
  • src/components/onboarding/StepCard.tsx
  • src/components/onboarding/ProgressBar.tsx
  • src/types/onboarding.ts

2.2 OnboardingWizard Container

Task: Create main wizard container with state management

Steps:

  1. 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>
  );
};
  1. Create page route:
// src/app/dashboard/onboarding/page.tsx
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';

export default function OnboardingPage() {
  return <OnboardingWizard />;
}
  1. 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.tsx
  • src/app/dashboard/onboarding/page.tsx

Files Modified:

  • changelog.MD

2.3 Reusable Components

Task: Implement shared UI components

2.3.1 RippleButton

Steps:

  1. 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.tsx
  • src/components/onboarding/StepCard.tsx
  • src/components/onboarding/ProgressBar.tsx

Phase 3: Step Components (Days 6-10)

3.1 Step 0: WelcomeStep

Task: Create welcome landing screen

Steps:

  1. 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:

  1. 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:

  1. 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();
    },
  });
};
  1. 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.ts
  • src/components/onboarding/steps/ApiKeyStep.tsx

Files Modified:

  • changelog.MD

3.4 Step 3: PickOrgStep

Task: Organization selection with device inventory

Steps:

  1. 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
  });
};
  1. 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;
}
  1. 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.ts
  • src/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:

  1. 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,
  });
};
  1. 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:

  1. 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();
    },
  });
};
  1. 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)';
}
  1. 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.ts
  • src/lib/onboarding-utils.ts

3.7 Step 6: SuccessStep

Task: Celebration screen with auto-redirect

Steps:

  1. 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:

  1. Update OnboardingWizard with complete navigation:
// src/components/onboarding/OnboardingWizard.tsx (update)
const goBack = () => setCurrentStep(prev => Math.max(0, prev - 1));
  1. Pass back handlers to relevant steps:
{currentStep === 5 && (
  <SummaryStep
    isActive={true}
    state={state}
    onFinish={nextStep}
    onBack={goBack}
  />
)}
  1. 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:

  1. 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;
  }
}
  1. Wrap OnboardingWizard:
// src/app/dashboard/onboarding/page.tsx (update)
import { OnboardingErrorBoundary } from '@/components/onboarding/OnboardingErrorBoundary';

export default function OnboardingPage() {
  return (
    <OnboardingErrorBoundary>
      <OnboardingWizard />
    </OnboardingErrorBoundary>
  );
}
  1. 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');
}
  1. 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.tsx
  • src/hooks/api/useOnboardingApi.ts

4.3 Loading States & Skeletons

Task: Add loading skeletons for better UX

Steps:

  1. 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>
  );
};
  1. 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>
  );
}
  1. 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.tsx
  • src/components/onboarding/NetworkSkeleton.tsx

4.4 Accessibility (a11y)

Task: Ensure WCAG 2.1 AA compliance

Steps:

  1. 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]);
  1. Add ARIA labels:
<motion.button
  aria-label={`Select ${tech.name}`}
  aria-pressed={isSelected}
  role="button"
  tabIndex={0}
  // ...
>
  1. Add focus management:
import { useEffect, useRef } from 'react';

const firstFocusableRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
  if (isActive) {
    firstFocusableRef.current?.focus();
  }
}, [isActive]);
  1. 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:

  1. 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)
  2. Adjust StepCard padding:

<div className="w-full max-w-2xl bg-card shadow-elegant rounded-2xl p-6 md:p-12">
  1. 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"
  1. 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:

  1. Analyze bundle:
npm run build
npx @next/bundle-analyzer
  1. 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>
    ),
  }
);
  1. Prefetch data:
// In ApiKeyStep, after validation success
await queryClient.prefetchQuery({
  queryKey: ['organizations', apiKey],
  queryFn: () => fetchOrganizations(apiKey),
});
  1. Optimize images:

    • Use Next.js Image component
    • Add proper width/height
    • Use WebP format
  2. 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:

  1. Reduce animation duration in production:
const ANIMATION_DURATION = process.env.NODE_ENV === 'development' ? 400 : 300;

<motion.div
  transition={{ duration: ANIMATION_DURATION / 1000 }}
>
  1. 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 }}
>
  1. 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:

  1. Install testing dependencies:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
  1. 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)');
  });
});
  1. 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:

  1. 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...
];
  1. 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:

  1. 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
  1. 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:

  1. Create production build:
cd src/SplashPage.Web.Ui
npm run build
  1. Test production build locally:
npm start
  1. Verify all features work in production mode

  2. 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:

  1. Create .env.production:
NEXT_PUBLIC_API_URL=https://api.splashpage.com
NEXT_PUBLIC_MERAKI_DASHBOARD_URL=https://dashboard.meraki.com
  1. Add to deployment pipeline:
# .github/workflows/deploy.yml
- name: Set environment variables
  run: |
    echo "NEXT_PUBLIC_API_URL=${{ secrets.API_URL }}" >> .env.production
  1. 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:

  1. 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;
}
  1. Test redirect:
curl -I https://splashpage.com/Onboarding
# Should return 301 with Location: /dashboard/onboarding
  1. 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:

  1. 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',
  });
}
  1. 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',
});
  1. 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.tsx
  • src/components/onboarding/OnboardingWizard.tsx

6.5 Go Live

Task: Deploy to production

Steps:

  1. Run final checklist:

    • All tests passing
    • Production build successful
    • Environment variables configured
    • Documentation complete
    • Monitoring enabled
    • Redirect configured
  2. 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
  1. Verify deployment:

  2. 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

  1. Monitor Performance

    • Review Lighthouse scores weekly
    • Optimize slow queries
    • Update dependencies monthly
  2. User Feedback

    • Collect user feedback via surveys
    • Implement UX improvements
    • Track completion rates
  3. Backend Optimization

    • Review Meraki API call patterns
    • Optimize caching strategy
    • Reduce database queries
  4. 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:

  1. Immediate: Redirect /dashboard/onboarding/Onboarding (legacy MVC)
  2. Within 1 hour: Disable Next.js route in Nginx
  3. Within 24 hours: Fix critical bugs and redeploy
  4. 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


End of Action Plan