sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13

Synced files:
- Session logs updated
- Latest context and credentials
- Command/directive updates

Machine: Mikes-MacBook-Air.local
Timestamp: 2026-03-09 08:14:13

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 08:14:13 -07:00
parent f81872784b
commit a1a19f8c00
59 changed files with 14435 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MSP Quote Wizard - Get a custom IT services quote for your business" />
<title>MSP Quote Wizard | AZ Computer Guru</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"name": "msp-quote-wizard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"framer-motion": "^12.35.2",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -0,0 +1,26 @@
import { WizardContainer } from '@/components/wizard/WizardContainer'
function App() {
return (
<div className="min-h-screen bg-white">
<header className="bg-[#333d49] text-white py-4 px-6">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<h1 className="text-xl font-semibold">MSP Quote Wizard</h1>
<span className="text-sm text-gray-300">Powered by AZ Computer Guru</span>
</div>
</header>
<main className="py-8">
<WizardContainer />
</main>
<footer className="bg-[#113559] text-white py-6 px-6 mt-auto">
<div className="max-w-6xl mx-auto text-center text-sm">
<p>&copy; {new Date().getFullYear()} AZ Computer Guru. All rights reserved.</p>
</div>
</footer>
</div>
)
}
export default App

View File

@@ -0,0 +1,59 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, HelpCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ExpandableInfoProps {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
icon?: React.ReactNode;
className?: string;
}
export function ExpandableInfo({
title,
children,
defaultExpanded = false,
icon,
className,
}: ExpandableInfoProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-3">
{icon || <HelpCircle className="w-5 h-5 text-[#fe7400]" />}
<span className="font-medium text-[#333d49]">{title}</span>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-gray-400" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="px-4 pb-4 pt-0 text-sm text-gray-600 border-t border-gray-100">
<div className="pt-4">{children}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { cn, formatCurrency } from '@/lib/utils';
import type { PricingTier } from '@/types/quote';
export interface PricingCardProps {
tier: PricingTier;
isSelected: boolean;
deviceCount: number;
onSelect: (tierId: string) => void;
}
export function PricingCard({ tier, isSelected, deviceCount, onSelect }: PricingCardProps) {
const monthlyEstimate = tier.basePrice + tier.perDevicePrice * deviceCount;
return (
<motion.div
whileHover={{ y: -4 }}
transition={{ duration: 0.2 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={cn(
'relative overflow-hidden',
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
)}
>
{/* Recommended badge */}
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
Recommended
</div>
</div>
)}
<div className="p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-sm text-gray-500 mt-1">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-6">
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-[#333d49]">
{formatCurrency(monthlyEstimate)}
</span>
<span className="text-gray-500">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
</p>
</div>
{/* Features */}
<ul className="space-y-2 mb-6">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
{/* Select button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
onClick={() => onSelect(tier.id)}
>
{isSelected ? 'Selected' : 'Select Plan'}
</Button>
</div>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,116 @@
import { Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PricingTier } from '@/types/quote';
export interface TierComparisonProps {
tiers: PricingTier[];
selectedTier?: string;
onSelectTier: (tierId: string) => void;
}
interface FeatureRow {
name: string;
essential: boolean | string;
professional: boolean | string;
enterprise: boolean | string;
}
const comparisonFeatures: FeatureRow[] = [
{ name: 'Remote Monitoring', essential: true, professional: true, enterprise: true },
{ name: 'Help Desk Support', essential: '8x5', professional: '24x7', enterprise: '24x7 Priority' },
{ name: 'Patch Management', essential: true, professional: true, enterprise: true },
{ name: 'Antivirus Protection', essential: 'Basic', professional: 'Advanced', enterprise: 'Advanced' },
{ name: 'Backup & Recovery', essential: false, professional: true, enterprise: true },
{ name: 'Network Monitoring', essential: false, professional: true, enterprise: true },
{ name: 'On-Site Support', essential: false, professional: 'Limited', enterprise: 'Unlimited' },
{ name: 'Vendor Management', essential: false, professional: true, enterprise: true },
{ name: 'Dedicated Account Manager', essential: false, professional: false, enterprise: true },
{ name: 'Virtual CIO Services', essential: false, professional: false, enterprise: true },
{ name: 'Compliance Management', essential: false, professional: false, enterprise: true },
{ name: 'Security Training', essential: false, professional: false, enterprise: true },
{ name: 'Business Reviews', essential: 'Annual', professional: 'Quarterly', enterprise: 'Monthly' },
];
export function TierComparison({ tiers, selectedTier, onSelectTier }: TierComparisonProps) {
const renderCell = (value: boolean | string) => {
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-300 mx-auto" />
);
}
return <span className="text-sm text-[#333d49]">{value}</span>;
};
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left p-4 border-b border-gray-200 bg-gray-50">
<span className="font-semibold text-[#333d49]">Feature</span>
</th>
{tiers.map((tier) => (
<th
key={tier.id}
className={cn(
'p-4 border-b border-gray-200 text-center cursor-pointer transition-colors',
selectedTier === tier.id
? 'bg-[#fe7400]/10'
: 'bg-gray-50 hover:bg-gray-100'
)}
onClick={() => onSelectTier(tier.id)}
>
<span
className={cn(
'font-semibold',
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
)}
>
{tier.name}
</span>
{tier.recommended && (
<span className="block text-xs text-[#fe7400] mt-1">Recommended</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{comparisonFeatures.map((feature, index) => (
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
<td className="p-4 border-b border-gray-100 text-sm text-gray-600">
{feature.name}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.essential)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.professional)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
)}
>
{renderCell(feature.enterprise)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { PricingCard, type PricingCardProps } from './PricingCard';
export { ExpandableInfo, type ExpandableInfoProps } from './ExpandableInfo';
export { TierComparison, type TierComparisonProps } from './TierComparison';

View File

@@ -0,0 +1,87 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart'> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
children,
...props
},
ref
) => {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
secondary:
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
outline:
'border-2 border-[#333d49] text-[#333d49] hover:bg-[#333d49] hover:text-white focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-base',
lg: 'px-7 py-3.5 text-lg',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
children
)}
</motion.button>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,137 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface CardProps {
variant?: 'default' | 'elevated' | 'outlined' | 'highlighted';
padding?: 'none' | 'sm' | 'md' | 'lg';
hoverable?: boolean;
className?: string;
children?: ReactNode;
onClick?: () => void;
}
const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
className,
variant = 'default',
padding = 'md',
hoverable = false,
children,
onClick,
},
ref
) => {
const baseStyles = 'rounded-xl transition-all duration-200';
const variants = {
default: 'bg-white border border-gray-200',
elevated: 'bg-white shadow-lg',
outlined: 'bg-transparent border-2 border-[#333d49]',
highlighted: 'bg-white border-2 border-[#fe7400] shadow-lg',
};
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const hoverStyles = hoverable
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
: '';
if (hoverable) {
return (
<motion.div
ref={ref}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
hoverStyles,
className
)}
onClick={onClick}
>
{children}
</motion.div>
);
}
return (
<div
ref={ref}
className={cn(
baseStyles,
variants[variant],
paddings[padding],
className
)}
onClick={onClick}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
// Card subcomponents
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mb-4 pb-4 border-b border-gray-100', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-xl font-semibold text-[#333d49]', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 mt-1', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mt-4 pt-4 border-t border-gray-100 flex items-center', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -0,0 +1,61 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, type = 'text', ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[#333d49] mb-1.5"
>
{label}
</label>
)}
<input
id={inputId}
type={type}
ref={ref}
className={cn(
'w-full px-4 py-2.5 rounded-lg border transition-all duration-200',
'text-[#333d49] placeholder-gray-400',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
: 'border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/20',
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-500">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,56 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export interface ProgressBarProps {
progress: number;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'accent';
className?: string;
}
export function ProgressBar({
progress,
showLabel = false,
size = 'md',
variant = 'accent',
className,
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const sizes = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
};
const variants = {
default: 'bg-[#333d49]',
accent: 'bg-[#fe7400]',
};
return (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-1.5">
<span className="text-sm font-medium text-[#333d49]">Progress</span>
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
</div>
)}
<div
className={cn('w-full bg-gray-200 rounded-full overflow-hidden', sizes[size])}
role="progressbar"
aria-valuenow={clampedProgress}
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className={cn('h-full rounded-full', variants[variant])}
initial={{ width: 0 }}
animate={{ width: `${clampedProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export { Button, type ButtonProps } from './Button';
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
type CardProps,
} from './Card';
export { Input, type InputProps } from './Input';
export { ProgressBar, type ProgressBarProps } from './ProgressBar';

View File

@@ -0,0 +1,341 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent } from '@/components/ui';
import { WizardProgress } from './WizardProgress';
import { WizardNavigation } from './WizardNavigation';
import { useWizard } from '@/hooks/useWizard';
import { useQuote } from '@/hooks/useQuote';
import {
Step1CompanyProfile,
Step2GPSMonitoring,
Step3SupportPlan,
Step4VoIP,
Step5WebEmail,
Step6Summary,
Step7Contact,
} from './steps';
import {
Building2,
Monitor,
Headphones,
Phone,
Globe,
FileCheck,
Send,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
/**
* WizardContainer - Main container for the MSP Quote Wizard
*
* Orchestrates the 7-step wizard flow:
* 1. Company Profile
* 2. GPS Monitoring
* 3. Support Plan
* 4. VoIP Phone System
* 5. Web & Email
* 6. Review Quote
* 7. Contact & Submit
*/
const stepIcons = [Building2, Monitor, Headphones, Phone, Globe, FileCheck, Send];
export function WizardContainer() {
const wizard = useWizard();
const quote = useQuote();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const StepIcon = stepIcons[wizard.currentStep] || Building2;
const currentStepData = wizard.steps[wizard.currentStep];
const handleNext = () => {
// Calculate quote before moving to summary
if (wizard.currentStep === 4) {
quote.calculateQuote();
}
wizard.nextStep();
};
const handlePrev = () => {
wizard.prevStep();
};
const handleSubmit = async () => {
setIsSubmitting(true);
// Calculate final quote
const result = quote.calculateQuote();
try {
// Simulate API submission
await new Promise((resolve) => setTimeout(resolve, 2000));
// Log submission (in production, this would send to an API)
console.log('Quote submitted:', {
quoteData: quote.quoteData,
quoteResult: result,
timestamp: new Date().toISOString(),
});
setSubmitSuccess(true);
} catch (error) {
console.error('Submission error:', error);
// Handle error state here
} finally {
setIsSubmitting(false);
}
};
const handleGoToStep = (step: number) => {
wizard.goToStep(step);
};
// Validate current step for "Next" button
const isNextDisabled = (): boolean => {
switch (wizard.currentStep) {
case 0: // Company Profile
return quote.quoteData.company.endpointCount < 1;
case 6: // Contact
return (
!quote.quoteData.contact.name.trim() ||
!quote.quoteData.contact.email.trim() ||
!quote.quoteData.contact.agreedToTerms
);
default:
return false;
}
};
// Render current step content
const renderStepContent = () => {
switch (wizard.currentStep) {
case 0:
return (
<Step1CompanyProfile
companyInfo={quote.quoteData.company}
onUpdateCompany={quote.updateCompany}
onSetEndpointCount={quote.setEndpointCount}
onSetIndustry={quote.setIndustry}
/>
);
case 1:
return (
<Step2GPSMonitoring
gpsSelection={quote.quoteData.gps}
onSetGPSTier={quote.setGPSTier}
onSetEquipmentEnabled={quote.setEquipmentEnabled}
onSetEquipmentCount={quote.setEquipmentCount}
getGPSMonthly={quote.getGPSMonthly}
/>
);
case 2:
return (
<Step3SupportPlan
supportSelection={quote.quoteData.support}
endpointCount={quote.quoteData.company.endpointCount}
onSetSupportPlan={quote.setSupportPlan}
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
onSetBlockTime={quote.setBlockTime}
getSupportMonthly={quote.getSupportMonthly}
/>
);
case 3:
return (
<Step4VoIP
voipSelection={quote.quoteData.voip}
onSetVoIPEnabled={quote.setVoIPEnabled}
onSetVoIPTier={quote.setVoIPTier}
onSetVoIPUserCount={quote.setVoIPUserCount}
onAddHardware={quote.addHardware}
onRemoveHardware={quote.removeHardware}
onUpdateHardwareQuantity={quote.updateHardwareQuantity}
getVoIPMonthly={quote.getVoIPMonthly}
getVoIPOneTime={quote.getVoIPOneTime}
/>
);
case 4:
return (
<Step5WebEmail
webHostingSelection={quote.quoteData.webHosting}
emailSelection={quote.quoteData.email}
onSetWebHostingEnabled={quote.setWebHostingEnabled}
onSetWebHostingTier={quote.setWebHostingTier}
onSetEmailEnabled={quote.setEmailEnabled}
onSetEmailProvider={quote.setEmailProvider}
onSetEmailTier={quote.setEmailTier}
onSetMailboxCount={quote.setMailboxCount}
getWebHostingMonthly={quote.getWebHostingMonthly}
getEmailMonthly={quote.getEmailMonthly}
/>
);
case 5:
return (
<Step6Summary
quoteData={quote.quoteData}
quoteResult={quote.quoteResult}
onGoToStep={handleGoToStep}
onCalculateQuote={quote.calculateQuote}
/>
);
case 6:
return (
<Step7Contact
contactInfo={quote.quoteData.contact}
companyNameFromStep1={quote.quoteData.company.name}
quoteResult={quote.quoteResult}
onUpdateContact={quote.updateContact}
onSetContactPreference={quote.setContactPreference}
onSetAgreedToTerms={quote.setAgreedToTerms}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
);
default:
return null;
}
};
// Success state
if (submitSuccess) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card variant="elevated" padding="lg">
<CardContent>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-12"
>
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
className="w-10 h-10 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-3xl font-bold text-[#333d49] mb-4">
Quote Request Submitted!
</h2>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Thank you for your interest. Our team will review your quote and
contact you within 24 hours.
</p>
{quote.quoteResult && (
<div className="bg-gray-50 rounded-lg p-6 max-w-sm mx-auto mb-8">
<p className="text-sm text-gray-500 mb-2">Your Estimated Monthly Total</p>
<p className="text-4xl font-bold text-[#fe7400]">
{formatCurrency(quote.quoteResult.monthlyTotal)}
<span className="text-lg font-normal text-gray-500">/mo</span>
</p>
</div>
)}
<button
onClick={() => {
quote.resetQuote();
wizard.resetWizard();
setSubmitSuccess(false);
}}
className="text-[#fe7400] hover:text-[#e56800] font-medium"
>
Start a New Quote
</button>
</motion.div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
{/* Progress indicator */}
<div className="mb-8">
<WizardProgress
steps={wizard.steps}
currentStep={wizard.currentStep}
onStepClick={wizard.goToStep}
/>
</div>
{/* Main wizard card */}
<Card variant="elevated" padding="lg">
<CardContent>
{/* Step header */}
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-gray-100">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#fe7400]/10">
<StepIcon className="w-6 h-6 text-[#fe7400]" />
</div>
<div>
<h2 className="text-2xl font-semibold text-[#333d49]">
{currentStepData?.title}
</h2>
<p className="text-gray-500">{currentStepData?.description}</p>
</div>
</div>
{/* Step content with animation */}
<AnimatePresence mode="wait">
<motion.div
key={wizard.currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="min-h-[400px]"
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
{/* Navigation - hidden on contact step (has its own submit) */}
{wizard.currentStep !== 6 && (
<WizardNavigation
onNext={handleNext}
onPrev={handlePrev}
onSubmit={handleSubmit}
isFirstStep={wizard.isFirstStep}
isLastStep={wizard.isLastStep}
isNextDisabled={isNextDisabled()}
isSubmitting={isSubmitting}
/>
)}
</CardContent>
</Card>
{/* Quick stats - show running total */}
<div className="mt-6 grid grid-cols-3 gap-4">
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Endpoints</p>
<p className="text-2xl font-bold text-[#333d49]">
{quote.quoteData.company.endpointCount}
</p>
</Card>
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Est. Monthly</p>
<p className="text-2xl font-bold text-[#fe7400]">
{formatCurrency(
quote.getGPSMonthly() +
quote.getSupportMonthly() +
quote.getVoIPMonthly() +
quote.getWebHostingMonthly() +
quote.getEmailMonthly()
)}
</p>
</Card>
<Card variant="default" padding="sm" className="text-center">
<p className="text-sm text-gray-500">Progress</p>
<p className="text-2xl font-bold text-[#333d49]">{wizard.progress}%</p>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui';
export interface WizardNavigationProps {
onNext: () => void;
onPrev: () => void;
onSubmit?: () => void;
isFirstStep: boolean;
isLastStep: boolean;
isNextDisabled?: boolean;
isSubmitting?: boolean;
}
export function WizardNavigation({
onNext,
onPrev,
onSubmit,
isFirstStep,
isLastStep,
isNextDisabled = false,
isSubmitting = false,
}: WizardNavigationProps) {
return (
<div className="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
<Button
type="button"
variant="outline"
onClick={onPrev}
disabled={isFirstStep}
className={isFirstStep ? 'invisible' : ''}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
{isLastStep ? (
<Button
type="button"
variant="primary"
onClick={onSubmit}
isLoading={isSubmitting}
disabled={isNextDisabled || isSubmitting}
>
Get My Quote
</Button>
) : (
<Button
type="button"
variant="primary"
onClick={onNext}
disabled={isNextDisabled}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import type { WizardStep } from '@/types/quote';
import { cn } from '@/lib/utils';
export interface WizardProgressProps {
steps: WizardStep[];
currentStep: number;
onStepClick?: (stepIndex: number) => void;
}
export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgressProps) {
const isCompactMode = steps.length > 5;
return (
<nav aria-label="Progress" className="w-full">
<ol className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = step.isComplete;
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
return (
<li
key={step.id}
className={cn(
'relative flex-1',
index !== steps.length - 1 && (isCompactMode ? 'pr-4 sm:pr-8' : 'pr-8 sm:pr-20')
)}
>
{/* Connector line */}
{index !== steps.length - 1 && (
<div
className={cn(
'absolute top-4 right-0 h-0.5 bg-gray-200',
isCompactMode ? 'left-6' : 'left-8'
)}
aria-hidden="true"
>
<motion.div
className="h-full bg-[#fe7400]"
initial={{ width: '0%' }}
animate={{ width: isCompleted ? '100%' : '0%' }}
transition={{ duration: 0.3 }}
/>
</div>
)}
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'group flex flex-col items-center',
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
)}
aria-current={isCurrent ? 'step' : undefined}
>
{/* Step circle */}
<motion.div
className={cn(
'relative z-10 flex items-center justify-center rounded-full border-2 transition-colors duration-200',
isCompactMode ? 'h-6 w-6' : 'h-8 w-8',
isCompleted
? 'bg-[#fe7400] border-[#fe7400]'
: isCurrent
? 'border-[#fe7400] bg-white'
: 'border-gray-300 bg-white'
)}
whileHover={isClickable ? { scale: 1.1 } : {}}
whileTap={isClickable ? { scale: 0.95 } : {}}
>
{isCompleted ? (
<Check className={cn(isCompactMode ? 'h-3 w-3' : 'h-4 w-4', 'text-white')} />
) : (
<span
className={cn(
'font-semibold',
isCompactMode ? 'text-xs' : 'text-sm',
isCurrent ? 'text-[#fe7400]' : 'text-gray-500'
)}
>
{index + 1}
</span>
)}
</motion.div>
{/* Step label - hidden on mobile for compact mode */}
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
<span
className={cn(
'font-medium whitespace-nowrap',
isCompactMode ? 'text-[10px]' : 'text-xs',
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-500'
)}
>
{isCompactMode ? step.title.split(' ')[0] : step.title}
</span>
</div>
</button>
</li>
);
})}
</ol>
{/* Mobile step indicator for compact mode */}
{isCompactMode && (
<div className="sm:hidden mt-4 text-center">
<span className="text-sm text-gray-500">
Step {currentStep + 1} of {steps.length}:
</span>
<span className="text-sm font-medium text-[#333d49] ml-1">
{steps[currentStep]?.title}
</span>
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,4 @@
export { WizardContainer } from './WizardContainer';
export { WizardProgress, type WizardProgressProps } from './WizardProgress';
export { WizardNavigation, type WizardNavigationProps } from './WizardNavigation';
export * from './steps';

View File

@@ -0,0 +1,133 @@
import { motion } from 'framer-motion';
import { Building2, Users, Briefcase, MessageSquare } from 'lucide-react';
import { Input } from '@/components/ui';
import { industries } from '@/lib/pricing-data';
import type { CompanyInfo, Industry } from '@/types/quote';
export interface Step1CompanyProfileProps {
companyInfo: CompanyInfo;
onUpdateCompany: (data: Partial<CompanyInfo>) => void;
onSetEndpointCount: (count: number) => void;
onSetIndustry: (industry: Industry | '') => void;
}
export function Step1CompanyProfile({
companyInfo,
onUpdateCompany,
onSetEndpointCount,
onSetIndustry,
}: Step1CompanyProfileProps) {
const handleEndpointChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1) {
onSetEndpointCount(value);
}
};
const handleIndustryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onSetIndustry(e.target.value as Industry | '');
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Company Name (Optional) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<Input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Enter your company name"
className="max-w-md"
/>
</div>
{/* Number of Endpoints (Required) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Users className="w-4 h-4 text-[#fe7400]" />
Number of Endpoints / Employees
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-4">
<Input
type="number"
min={1}
value={companyInfo.endpointCount}
onChange={handleEndpointChange}
className="w-32"
/>
<span className="text-sm text-gray-500">
devices requiring monitoring and support
</span>
</div>
<p className="text-xs text-gray-400">
Include workstations, laptops, and servers that need IT support
</p>
</div>
{/* Industry Selection */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Briefcase className="w-4 h-4 text-[#fe7400]" />
Industry
</label>
<select
value={companyInfo.industry}
onChange={handleIndustryChange}
className="w-full max-w-md px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200"
>
<option value="">Select your industry...</option>
{industries.map((industry) => (
<option key={industry} value={industry}>
{industry}
</option>
))}
</select>
<p className="text-xs text-gray-400">
This helps us understand compliance requirements and best practices for your sector
</p>
</div>
{/* Notes (Optional) */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
What brings you here today?
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={companyInfo.notes}
onChange={(e) => onUpdateCompany({ notes: e.target.value })}
placeholder="Tell us about your current IT challenges or what you're looking for..."
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Info Card */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="bg-[#fe7400]/5 border border-[#fe7400]/20 rounded-lg p-4 mt-6"
>
<h4 className="font-medium text-[#333d49] mb-2">Why we ask this</h4>
<p className="text-sm text-gray-600">
Understanding your business size and industry helps us recommend the right
service tier and identify any compliance requirements (like HIPAA for healthcare
or PCI-DSS for retail) that may affect your IT needs.
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,230 @@
import { motion } from 'framer-motion';
import { Check, Server, HardDrive } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { GPSSelection, GPSTierId } from '@/types/quote';
export interface Step2GPSMonitoringProps {
gpsSelection: GPSSelection;
onSetGPSTier: (tierId: GPSTierId) => void;
onSetEquipmentEnabled: (enabled: boolean) => void;
onSetEquipmentCount: (count: number) => void;
getGPSMonthly: () => number;
}
export function Step2GPSMonitoring({
gpsSelection,
onSetGPSTier,
onSetEquipmentEnabled,
onSetEquipmentCount,
getGPSMonthly,
}: Step2GPSMonitoringProps) {
const calculateEquipmentPrice = () => {
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
return 0;
}
const additionalDevices = Math.max(0, gpsSelection.equipmentDeviceCount - equipmentMonitoring.baseDevices);
return equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Endpoint Count Display */}
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]">Endpoints to Monitor</span>
</div>
<span className="text-2xl font-bold text-[#fe7400]">
{gpsSelection.endpointCount}
</span>
</div>
{/* Tier Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{gpsTiers.map((tier, index) => {
const isSelected = gpsSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetGPSTier(tier.id)}
>
{/* Recommended Badge */}
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
Recommended
</div>
</div>
)}
<div className="p-5">
{/* Header */}
<div className="mb-3">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-sm text-gray-500">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-4">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
</p>
</div>
{/* Features */}
<ul className="space-y-2 mb-4">
{tier.features.slice(0, 4).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
{tier.features.length > 4 && (
<li className="text-xs text-[#fe7400]">
+{tier.features.length - 4} more features
</li>
)}
</ul>
{/* Select Button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Equipment Monitoring Section */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200 rounded-lg p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-[#fe7400]" />
<div>
<h4 className="font-medium text-[#333d49]">Equipment Pack Monitoring</h4>
<p className="text-sm text-gray-500">
Monitor routers, switches, printers, and other network equipment
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={gpsSelection.includeEquipment}
onChange={(e) => onSetEquipmentEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
</label>
</div>
{gpsSelection.includeEquipment && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-4 pt-4 border-t border-gray-100"
>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-600">Number of devices:</label>
<input
type="number"
min={1}
value={gpsSelection.equipmentDeviceCount}
onChange={(e) => onSetEquipmentCount(parseInt(e.target.value, 10) || 1)}
className="w-24 px-3 py-2 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400]"
/>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-600">
<span className="font-medium">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
{' '}for up to {equipmentMonitoring.baseDevices} devices
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
<span>
{' + '}
<span className="font-medium">
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
</span>
{' for additional devices'}
</span>
)}
</p>
<p className="text-sm font-medium text-[#fe7400] mt-1">
Equipment total: {formatCurrency(calculateEquipmentPrice())}/month
</p>
</div>
</motion.div>
)}
</motion.div>
{/* Expandable Feature Info */}
<ExpandableInfo title="What's included in GPS Monitoring?">
<ul className="space-y-2">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Remote Monitoring:</strong> 24/7 monitoring of system health, performance, and security</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Patch Management:</strong> Automated Windows and third-party application updates</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Antivirus:</strong> Enterprise-grade protection with real-time threat detection</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Help Desk:</strong> Access to our technical support team for issues and questions</span>
</li>
</ul>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">GPS Monitoring Monthly Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getGPSMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,235 @@
import { motion } from 'framer-motion';
import { Check, Clock, DollarSign, Zap } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { SupportSelection, SupportPlanId, BlockTimeId } from '@/types/quote';
export interface Step3SupportPlanProps {
supportSelection: SupportSelection;
endpointCount: number;
onSetSupportPlan: (planId: SupportPlanId) => void;
onSetBlockTimeEnabled: (enabled: boolean) => void;
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
getSupportMonthly: () => number;
}
export function Step3SupportPlan({
supportSelection,
endpointCount,
onSetSupportPlan,
onSetBlockTimeEnabled,
onSetBlockTime,
getSupportMonthly,
}: Step3SupportPlanProps) {
// Recommend plan based on endpoint count
const getRecommendedPlan = (): SupportPlanId => {
if (endpointCount <= 10) return 'essential';
if (endpointCount <= 25) return 'standard';
if (endpointCount <= 50) return 'premium';
return 'priority';
};
const recommendedPlanId = getRecommendedPlan();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Plan Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{supportPlans.map((plan, index) => {
const isSelected = supportSelection.planId === plan.id;
const isRecommended = plan.id === recommendedPlanId;
return (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : isRecommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
isRecommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetSupportPlan(plan.id)}
>
{/* Recommended Badge */}
{isRecommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
For You
</div>
</div>
)}
<div className="p-4">
{/* Header */}
<h3 className="text-lg font-semibold text-[#333d49] mb-1">{plan.name}</h3>
<p className="text-xs text-gray-500 mb-3">{plan.description}</p>
{/* Pricing */}
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]">
{formatCurrency(plan.monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
</div>
</div>
{/* Hours Included */}
<div className="flex items-center gap-2 mb-3 p-2 bg-gray-50 rounded-lg">
<Clock className="w-4 h-4 text-[#fe7400]" />
<span className="text-sm font-medium text-[#333d49]">
{plan.includedHours} hrs included
</span>
</div>
{/* Effective Rate */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-600">
<DollarSign className="w-4 h-4" />
<span>
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
</span>
</div>
{/* Select Button */}
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Block Time Option */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200 rounded-lg p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-[#fe7400]" />
<div>
<h4 className="font-medium text-[#333d49]">Add Block Time</h4>
<p className="text-sm text-gray-500">
Pre-purchase additional support hours at a discounted rate
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={supportSelection.useBlockTime}
onChange={(e) => onSetBlockTimeEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
</label>
</div>
{supportSelection.useBlockTime && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-3 pt-4 border-t border-gray-100"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{blockTimeOptions.map((option) => {
const isSelected = supportSelection.blockTimeId === option.id;
return (
<div
key={option.id}
onClick={() => onSetBlockTime(option.id)}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-lg font-bold text-[#333d49]">
{option.hours} Hours
</div>
<div className="text-xl font-bold text-[#fe7400]">
{formatCurrency(option.price)}
</div>
<div className="text-sm text-gray-500">
{formatCurrency(option.effectiveHourlyRate)}/hr
</div>
{option.hours === 30 && (
<div className="mt-2 text-xs font-medium text-green-600">
Best Value
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
</motion.div>
{/* Expandable Info */}
<ExpandableInfo title="How does support work?">
<div className="space-y-3">
<p>
Your monthly support plan includes a set number of hours for help desk assistance,
remote troubleshooting, and project work. Hours roll over for 30 days if unused.
</p>
<ul className="space-y-2">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Help Desk:</strong> Phone, email, and chat support for daily IT questions</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>Remote Support:</strong> Screen sharing and remote control for quick fixes</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
</li>
</ul>
<p className="text-sm text-gray-500">
Block time is great for planned projects, office moves, or seasonal busy periods.
</p>
</div>
</ExpandableInfo>
{/* Monthly Total */}
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<div>
<span className="text-lg">Support Monthly Total</span>
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
<p className="text-sm opacity-75">
Includes{' '}
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hr block
</p>
)}
</div>
<span className="text-3xl font-bold">
{formatCurrency(getSupportMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,375 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Phone, Headphones, Plus, Minus, X } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { voipTiers, voipHardware } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { VoIPSelection, VoIPTierId, HardwareSelection } from '@/types/quote';
export interface Step4VoIPProps {
voipSelection: VoIPSelection;
onSetVoIPEnabled: (enabled: boolean) => void;
onSetVoIPTier: (tierId: VoIPTierId) => void;
onSetVoIPUserCount: (count: number) => void;
onAddHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
onRemoveHardware: (hardwareId: string) => void;
onUpdateHardwareQuantity: (hardwareId: string, quantity: number) => void;
getVoIPMonthly: () => number;
getVoIPOneTime: () => number;
}
export function Step4VoIP({
voipSelection,
onSetVoIPEnabled,
onSetVoIPTier,
onSetVoIPUserCount,
onAddHardware,
onRemoveHardware,
onUpdateHardwareQuantity,
getVoIPMonthly,
getVoIPOneTime,
}: Step4VoIPProps) {
const [showHardware, setShowHardware] = useState(false);
const getHardwareSelection = (hardwareId: string): HardwareSelection | undefined => {
return voipSelection.hardware.find((h) => h.hardwareId === hardwareId);
};
const handleHardwareToggle = (hardwareId: string, isRental: boolean) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
onRemoveHardware(hardwareId);
} else {
onAddHardware(hardwareId, 1, isRental);
}
};
const handleQuantityChange = (hardwareId: string, delta: number) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
const newQuantity = Math.max(1, existing.quantity + delta);
onUpdateHardwareQuantity(hardwareId, newQuantity);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* VoIP Toggle */}
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Phone className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Do you need business phones?</h3>
<p className="text-sm text-gray-500">
Modern VoIP phone system with advanced features
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={voipSelection.enabled}
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-medium text-gray-700">
{voipSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{voipSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* User Count */}
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
<label className="text-sm font-medium text-[#333d49]">Number of phone users:</label>
<Input
type="number"
min={1}
value={voipSelection.userCount}
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
/>
</div>
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{voipTiers.map((tier, index) => {
const isSelected = voipSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetVoIPTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerUser)}/user
</p>
</div>
<ul className="space-y-1 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Hardware Section */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowHardware(!showHardware)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<Headphones className="w-5 h-5 text-[#fe7400]" />
<span className="font-medium text-[#333d49]">
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-500">
{showHardware ? 'Hide' : 'Show'} options
</span>
</button>
<AnimatePresence>
{showHardware && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-4 space-y-3"
>
{voipHardware.map((hardware) => {
const selection = getHardwareSelection(hardware.id);
const isSelected = !!selection;
return (
<div
key={hardware.id}
className={`p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-[#333d49]">{hardware.name}</h4>
<p className="text-sm text-gray-500">{hardware.description}</p>
<div className="flex gap-4 mt-2 text-sm">
<span className="text-[#333d49]">
Buy: <strong>{formatCurrency(hardware.oneTimePrice)}</strong>
</span>
<span className="text-[#333d49]">
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
</span>
</div>
</div>
{isSelected ? (
<div className="flex items-center gap-3">
{/* Rental Toggle */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
className={`px-2 py-1 text-xs rounded ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
Buy
</button>
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
className={`px-2 py-1 text-xs rounded ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
Rent
</button>
</div>
{/* Quantity */}
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, -1)}
className="p-2 hover:bg-gray-100 rounded-l-lg"
disabled={selection.quantity <= 1}
>
<Minus className="w-4 h-4" />
</button>
<span className="w-8 text-center font-medium">
{selection.quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, 1)}
className="p-2 hover:bg-gray-100 rounded-r-lg"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Remove */}
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleHardwareToggle(hardware.id, false)}
>
Add (Buy)
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleHardwareToggle(hardware.id, true)}
>
Add (Rent)
</Button>
</div>
)}
</div>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Info */}
<ExpandableInfo title="VoIP Features & Benefits">
<ul className="space-y-2">
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Unlimited local and long-distance calling</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Mobile apps for iOS and Android - take calls anywhere</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Auto-attendant and professional voicemail</span>
</li>
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span>Keep your existing phone numbers</span>
</li>
</ul>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">VoIP Monthly Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getVoIPMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
{getVoIPOneTime() > 0 && (
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Hardware Purchase (One-Time)</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getVoIPOneTime())}
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{!voipSelection.enabled && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-8 text-gray-500"
>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>You can always add VoIP services later.</p>
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,378 @@
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Globe, Mail, Cloud, Server } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { webHostingTiers, emailTiers } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type {
WebHostingSelection,
WebHostingTierId,
EmailSelection,
EmailTierId,
EmailProvider,
} from '@/types/quote';
export interface Step5WebEmailProps {
webHostingSelection: WebHostingSelection;
emailSelection: EmailSelection;
onSetWebHostingEnabled: (enabled: boolean) => void;
onSetWebHostingTier: (tierId: WebHostingTierId) => void;
onSetEmailEnabled: (enabled: boolean) => void;
onSetEmailProvider: (provider: EmailProvider) => void;
onSetEmailTier: (tierId: EmailTierId) => void;
onSetMailboxCount: (count: number) => void;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
}
export function Step5WebEmail({
webHostingSelection,
emailSelection,
onSetWebHostingEnabled,
onSetWebHostingTier,
onSetEmailEnabled,
onSetEmailProvider,
onSetEmailTier,
onSetMailboxCount,
getWebHostingMonthly,
getEmailMonthly,
}: Step5WebEmailProps) {
const whmTiers = emailTiers.filter((t) => t.provider === 'whm');
const m365Tiers = emailTiers.filter((t) => t.provider === 'm365');
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
{/* Web Hosting Section */}
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Web Hosting</h3>
<p className="text-sm text-gray-500">
Managed WordPress hosting with SSL and backups
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={webHostingSelection.enabled}
onChange={(e) => onSetWebHostingEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-medium text-gray-700">
{webHostingSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{webHostingSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{webHostingTiers.map((tier, index) => {
const isSelected = webHostingSelection.tierId === tier.id;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetWebHostingTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-2xl font-bold text-[#333d49]">
{formatCurrency(tier.monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/mo</span>
</div>
<div className="flex gap-3 mb-3 text-xs text-gray-600">
<span>{tier.storage}</span>
<span>|</span>
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
</div>
<ul className="space-y-1 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Divider */}
<div className="border-t border-gray-200" />
{/* Email Section */}
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-[#fe7400]" />
<div>
<h3 className="font-semibold text-[#333d49]">Email Service</h3>
<p className="text-sm text-gray-500">
Professional business email hosting
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={emailSelection.enabled}
onChange={(e) => onSetEmailEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
<span className="ml-3 text-sm font-medium text-gray-700">
{emailSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
</div>
</div>
<AnimatePresence>
{emailSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{/* Mailbox Count */}
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
<label className="text-sm font-medium text-[#333d49]">
Number of mailboxes:
</label>
<Input
type="number"
min={1}
value={emailSelection.mailboxCount}
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
/>
</div>
{/* Provider Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
onClick={() => onSetEmailProvider('whm')}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
emailSelection.provider === 'whm'
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Server className="w-5 h-5 text-[#fe7400]" />
<h4 className="font-semibold text-[#333d49]">Self-Hosted (WHM)</h4>
</div>
<p className="text-sm text-gray-500">
Budget-friendly email hosting on our servers
</p>
</div>
<div
onClick={() => onSetEmailProvider('m365')}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
emailSelection.provider === 'm365'
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Cloud className="w-5 h-5 text-[#fe7400]" />
<h4 className="font-semibold text-[#333d49]">Microsoft 365</h4>
<span className="text-xs bg-[#fe7400] text-white px-2 py-0.5 rounded">
Recommended
</span>
</div>
<p className="text-sm text-gray-500">
Full Microsoft suite with Teams, OneDrive, and Office apps
</p>
</div>
</div>
{/* Tier Selection based on Provider */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(emailSelection.provider === 'whm' ? whmTiers : m365Tiers).map((tier, index) => {
const isSelected = emailSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
}`}
onClick={() => onSetEmailTier(tier.id)}
>
{tier.recommended && (
<div className="absolute top-0 right-0">
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
Popular
</div>
</div>
)}
<div className="p-4">
<h3 className="text-base font-semibold text-[#333d49]">{tier.name}</h3>
<p className="text-xs text-gray-500 mb-2">{tier.storage}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerMailbox)}/mailbox
</p>
</div>
<ul className="space-y-1 mb-3">
{tier.features.slice(0, 3).map((feature, idx) => (
<li key={idx} className="flex items-start gap-1.5 text-xs">
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Info */}
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
<div className="space-y-3">
<div>
<h5 className="font-medium text-[#333d49]">Self-Hosted (WHM)</h5>
<p className="text-sm text-gray-600">
Best for budget-conscious businesses that just need reliable email.
Includes webmail access and standard email features.
</p>
</div>
<div>
<h5 className="font-medium text-[#333d49]">Microsoft 365</h5>
<p className="text-sm text-gray-600">
Best for businesses that need collaboration tools. Includes Outlook,
Teams for video calls, OneDrive cloud storage, and the full Office
suite (Word, Excel, PowerPoint).
</p>
</div>
</div>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
{webHostingSelection.enabled && (
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Web Hosting</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getWebHostingMonthly())}
<span className="text-sm font-normal">/mo</span>
</span>
</div>
)}
{emailSelection.enabled && (
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
<span className="text-gray-700">Email Service</span>
<span className="text-xl font-bold text-[#333d49]">
{formatCurrency(getEmailMonthly())}
<span className="text-sm font-normal">/mo</span>
</span>
</div>
)}
{(webHostingSelection.enabled || emailSelection.enabled) && (
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
<span className="text-lg">Web & Email Total</span>
<span className="text-3xl font-bold">
{formatCurrency(getWebHostingMonthly() + getEmailMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,308 @@
import { motion } from 'framer-motion';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign } from 'lucide-react';
import { Button } from '@/components/ui';
import {
gpsTiers,
supportPlans,
blockTimeOptions,
voipTiers,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { QuoteData, QuoteResult } from '@/types/quote';
export interface Step6SummaryProps {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
onGoToStep: (step: number) => void;
onCalculateQuote: () => QuoteResult;
}
export function Step6Summary({
quoteData,
quoteResult,
onGoToStep,
onCalculateQuote,
}: Step6SummaryProps) {
// Calculate fresh quote if not available
const result = quoteResult || onCalculateQuote();
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
const supportPlan = supportPlans.find((p) => p.id === quoteData.support.planId);
const blockTime = quoteData.support.useBlockTime && quoteData.support.blockTimeId
? blockTimeOptions.find((b) => b.id === quoteData.support.blockTimeId)
: null;
const voipTier = voipTiers.find((t) => t.id === quoteData.voip.tierId);
const webTier = webHostingTiers.find((t) => t.id === quoteData.webHosting.tierId);
const emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
const handlePrint = () => {
window.print();
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Your Quote Summary</h2>
<p className="text-gray-500">Review your selections before submitting</p>
</div>
{/* Company Info */}
{quoteData.company.name && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-500">Quote prepared for:</p>
<p className="font-semibold text-[#333d49] text-lg">{quoteData.company.name}</p>
{quoteData.company.industry && (
<p className="text-sm text-gray-600">{quoteData.company.industry}</p>
)}
</div>
)}
{/* GPS Monitoring Section */}
<SummarySection
icon={<Monitor className="w-5 h-5" />}
title="GPS Monitoring"
monthlyTotal={result.gpsMonthly}
onEdit={() => onGoToStep(1)}
>
<div className="space-y-2">
<SummaryLine
label={`${gpsTier?.name} Plan (${quoteData.gps.endpointCount} endpoints)`}
value={formatCurrency(result.breakdown.gps.monitoring)}
/>
{quoteData.gps.includeEquipment && quoteData.gps.equipmentDeviceCount > 0 && (
<SummaryLine
label={`Equipment Pack (${quoteData.gps.equipmentDeviceCount} devices)`}
value={formatCurrency(result.breakdown.gps.equipment)}
/>
)}
</div>
</SummarySection>
{/* Support Plan Section */}
<SummarySection
icon={<Headphones className="w-5 h-5" />}
title="Support Plan"
monthlyTotal={result.supportMonthly}
onEdit={() => onGoToStep(2)}
>
<div className="space-y-2">
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
{blockTime && (
<SummaryLine
label={`Block Time (${blockTime.hours} hours)`}
value={formatCurrency(result.breakdown.support.blockTime)}
/>
)}
</div>
</SummarySection>
{/* VoIP Section */}
{quoteData.voip.enabled && (
<SummarySection
icon={<Phone className="w-5 h-5" />}
title="VoIP Phone System"
monthlyTotal={result.voipMonthly}
onEdit={() => onGoToStep(3)}
>
<div className="space-y-2">
<SummaryLine
label={`${voipTier?.name} Plan (${quoteData.voip.userCount} users)`}
value={formatCurrency(result.breakdown.voip.service)}
/>
{result.breakdown.voip.hardware > 0 && (
<SummaryLine
label="Hardware Rental"
value={formatCurrency(result.breakdown.voip.hardware)}
/>
)}
</div>
</SummarySection>
)}
{/* Web Hosting Section */}
{quoteData.webHosting.enabled && (
<SummarySection
icon={<Globe className="w-5 h-5" />}
title="Web Hosting"
monthlyTotal={result.webHostingMonthly}
onEdit={() => onGoToStep(4)}
>
<SummaryLine
label={`${webTier?.name} Plan (${webTier?.storage}, ${webTier?.sites === -1 ? 'unlimited' : webTier?.sites} sites)`}
value={formatCurrency(result.webHostingMonthly)}
/>
</SummarySection>
)}
{/* Email Section */}
{quoteData.email.enabled && (
<SummarySection
icon={<Mail className="w-5 h-5" />}
title="Email Service"
monthlyTotal={result.emailMonthly}
onEdit={() => onGoToStep(4)}
>
<SummaryLine
label={`${emailTier?.name} (${quoteData.email.mailboxCount} mailboxes)`}
value={formatCurrency(result.emailMonthly)}
/>
</SummarySection>
)}
{/* Totals */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="bg-[#333d49] text-white rounded-xl p-6 mt-8"
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg">Monthly Total</span>
<span className="text-4xl font-bold">
{formatCurrency(result.monthlyTotal)}
<span className="text-lg font-normal opacity-75">/mo</span>
</span>
</div>
{result.oneTimeTotal > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-white/20">
<span className="opacity-75">One-Time Costs (Hardware)</span>
<span className="text-xl font-semibold">
{formatCurrency(result.oneTimeTotal)}
</span>
</div>
)}
<div className="mt-6 pt-4 border-t border-white/20">
<div className="flex items-center justify-between text-sm opacity-75">
<span>Annual Investment</span>
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
</div>
</div>
</motion.div>
{/* Breakdown Card */}
<div className="bg-gray-50 rounded-lg p-5">
<h4 className="font-semibold text-[#333d49] mb-4 flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#fe7400]" />
Monthly Breakdown
</h4>
<div className="space-y-3">
<BreakdownRow label="GPS Monitoring" value={result.gpsMonthly} />
<BreakdownRow label="Support Plan" value={result.supportMonthly} />
{quoteData.voip.enabled && (
<BreakdownRow label="VoIP Phone System" value={result.voipMonthly} />
)}
{quoteData.webHosting.enabled && (
<BreakdownRow label="Web Hosting" value={result.webHostingMonthly} />
)}
{quoteData.email.enabled && (
<BreakdownRow label="Email Service" value={result.emailMonthly} />
)}
<div className="pt-3 border-t border-gray-200 flex justify-between font-bold text-lg">
<span className="text-[#333d49]">Total</span>
<span className="text-[#fe7400]">{formatCurrency(result.monthlyTotal)}/mo</span>
</div>
</div>
</div>
{/* Print Button */}
<div className="flex justify-center pt-4 print:hidden">
<Button
variant="outline"
onClick={handlePrint}
className="flex items-center gap-2"
>
<Printer className="w-4 h-4" />
Print Quote
</Button>
</div>
{/* Notes Section */}
<div className="text-center text-sm text-gray-500 pt-4">
<p>This is an estimate. Final pricing may vary based on specific requirements.</p>
<p>Prices are subject to change. Quote valid for 30 days.</p>
</div>
</motion.div>
);
}
// Helper Components
interface SummarySectionProps {
icon: React.ReactNode;
title: string;
monthlyTotal: number;
onEdit: () => void;
children: React.ReactNode;
}
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[#fe7400]">{icon}</span>
<span className="font-semibold text-[#333d49]">{title}</span>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-[#333d49]">
{formatCurrency(monthlyTotal)}/mo
</span>
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 text-sm text-[#fe7400] hover:text-[#e56800] transition-colors print:hidden"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</div>
<div className="p-4">{children}</div>
</motion.div>
);
}
interface SummaryLineProps {
label: string;
value: string;
}
function SummaryLine({ label, value }: SummaryLineProps) {
return (
<div className="flex justify-between text-sm">
<span className="text-gray-600">{label}</span>
<span className="font-medium text-[#333d49]">{value}</span>
</div>
);
}
interface BreakdownRowProps {
label: string;
value: number;
}
function BreakdownRow({ label, value }: BreakdownRowProps) {
return (
<div className="flex justify-between">
<span className="text-gray-600">{label}</span>
<span className="font-medium text-[#333d49]">{formatCurrency(value)}</span>
</div>
);
}

View File

@@ -0,0 +1,294 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } from 'lucide-react';
import { Input, Button } from '@/components/ui';
import { contactPreferences } from '@/lib/pricing-data';
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
import { formatCurrency } from '@/lib/utils';
export interface Step7ContactProps {
contactInfo: ContactInfo;
companyNameFromStep1: string;
quoteResult: QuoteResult | null;
onUpdateContact: (data: Partial<ContactInfo>) => void;
onSetContactPreference: (preference: ContactPreference) => void;
onSetAgreedToTerms: (agreed: boolean) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
interface FormErrors {
name?: string;
email?: string;
agreedToTerms?: string;
}
export function Step7Contact({
contactInfo,
companyNameFromStep1,
quoteResult,
onUpdateContact,
onSetContactPreference,
onSetAgreedToTerms,
onSubmit,
isSubmitting,
}: Step7ContactProps) {
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// Pre-fill company name if available
if (companyNameFromStep1 && !contactInfo.companyName) {
onUpdateContact({ companyName: companyNameFromStep1 });
}
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!contactInfo.name.trim()) {
newErrors.name = 'Name is required';
}
if (!contactInfo.email.trim()) {
newErrors.email = 'Email is required';
} else if (!validateEmail(contactInfo.email)) {
newErrors.email = 'Please enter a valid email address';
}
if (!contactInfo.agreedToTerms) {
newErrors.agreedToTerms = 'You must agree to the terms';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleBlur = (field: string) => {
setTouched((prev) => ({ ...prev, [field]: true }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit();
} else {
// Mark all fields as touched to show errors
setTouched({
name: true,
email: true,
agreedToTerms: true,
});
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="max-w-2xl mx-auto"
>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Get Your Quote</h2>
<p className="text-gray-500">
We will send your customized quote and contact you to discuss next steps.
</p>
</div>
{/* Quote Preview */}
{quoteResult && (
<div className="bg-[#fe7400]/10 border border-[#fe7400]/30 rounded-lg p-4 mb-6 flex items-center justify-between">
<span className="text-[#333d49] font-medium">Your Estimated Monthly Total:</span>
<span className="text-2xl font-bold text-[#fe7400]">
{formatCurrency(quoteResult.monthlyTotal)}/mo
</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Contact Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500">*</span>
</label>
<Input
type="text"
value={contactInfo.name}
onChange={(e) => onUpdateContact({ name: e.target.value })}
onBlur={() => handleBlur('name')}
placeholder="Your full name"
error={touched.name ? errors.name : undefined}
/>
</div>
{/* Email */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500">*</span>
</label>
<Input
type="email"
value={contactInfo.email}
onChange={(e) => onUpdateContact({ email: e.target.value })}
onBlur={() => handleBlur('email')}
placeholder="you@company.com"
error={touched.email ? errors.email : undefined}
/>
</div>
{/* Phone */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-400 font-normal">(recommended)</span>
</label>
<Input
type="tel"
value={contactInfo.phone}
onChange={(e) => onUpdateContact({ phone: e.target.value })}
placeholder="(555) 123-4567"
/>
</div>
{/* Company Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
</label>
<Input
type="text"
value={contactInfo.companyName}
onChange={(e) => onUpdateContact({ companyName: e.target.value })}
placeholder="Your company name"
/>
</div>
{/* Current IT Situation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={contactInfo.currentITSituation}
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
placeholder="Tell us about your current IT setup and any challenges you're facing..."
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Contact Preference */}
<div className="space-y-3">
<label className="text-sm font-medium text-[#333d49]">
Preferred Contact Method
</label>
<div className="flex gap-4">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="radio"
name="contactPreference"
value={pref.id}
checked={contactInfo.contactPreference === pref.id}
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
className="w-4 h-4 text-[#fe7400] border-gray-300 focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-700">{pref.label}</span>
</label>
))}
</div>
</div>
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={contactInfo.agreedToTerms}
onChange={(e) => {
onSetAgreedToTerms(e.target.checked);
handleBlur('agreedToTerms');
}}
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-600">
I agree to receive communications about my quote and understand that I can
unsubscribe at any time. I have read and agree to the{' '}
<a href="/privacy" className="text-[#fe7400] hover:underline">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:underline">
Terms of Service
</a>
.
<span className="text-red-500">*</span>
</span>
</label>
{touched.agreedToTerms && errors.agreedToTerms && (
<p className="text-sm text-red-500 ml-8">{errors.agreedToTerms}</p>
)}
</div>
{/* Submit Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="pt-6"
>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-lg py-4"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
</Button>
</motion.div>
</form>
{/* Trust Indicators */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-8 pt-6 border-t border-gray-200"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">No obligation quote</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Response within 24 hours</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Your data is secure</span>
</div>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,7 @@
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';
export { Step4VoIP, type Step4VoIPProps } from './Step4VoIP';
export { Step5WebEmail, type Step5WebEmailProps } from './Step5WebEmail';
export { Step6Summary, type Step6SummaryProps } from './Step6Summary';
export { Step7Contact, type Step7ContactProps } from './Step7Contact';

View File

@@ -0,0 +1,612 @@
import { useState, useCallback, useMemo } from 'react';
import type {
QuoteData,
QuoteResult,
QuoteBreakdown,
CompanyInfo,
GPSSelection,
SupportSelection,
VoIPSelection,
WebHostingSelection,
EmailSelection,
ContactInfo,
GPSTierId,
SupportPlanId,
BlockTimeId,
VoIPTierId,
WebHostingTierId,
EmailTierId,
EmailProvider,
Industry,
ContactPreference,
} from '@/types/quote';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
/**
* Initial state values
*/
const initialCompanyInfo: CompanyInfo = {
name: '',
endpointCount: 10,
industry: '',
notes: '',
};
const initialGPSSelection: GPSSelection = {
tierId: 'pro',
endpointCount: 10,
includeEquipment: false,
equipmentDeviceCount: 0,
};
const initialSupportSelection: SupportSelection = {
planId: 'standard',
useBlockTime: false,
blockTimeId: null,
};
const initialVoIPSelection: VoIPSelection = {
enabled: false,
tierId: 'voip-standard',
userCount: 0,
hardware: [],
};
const initialWebHostingSelection: WebHostingSelection = {
enabled: false,
tierId: 'hosting-business',
};
const initialEmailSelection: EmailSelection = {
enabled: false,
provider: 'm365',
tierId: 'm365-standard',
mailboxCount: 0,
};
const initialContactInfo: ContactInfo = {
name: '',
email: '',
phone: '',
companyName: '',
currentITSituation: '',
contactPreference: 'email',
agreedToTerms: false,
};
/**
* Hook return type
*/
export interface UseQuoteReturn {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
// Company updates
updateCompany: (data: Partial<CompanyInfo>) => void;
setEndpointCount: (count: number) => void;
setIndustry: (industry: Industry | '') => void;
// GPS updates
updateGPS: (data: Partial<GPSSelection>) => void;
setGPSTier: (tierId: GPSTierId) => void;
setEquipmentEnabled: (enabled: boolean) => void;
setEquipmentCount: (count: number) => void;
// Support updates
updateSupport: (data: Partial<SupportSelection>) => void;
setSupportPlan: (planId: SupportPlanId) => void;
setBlockTimeEnabled: (enabled: boolean) => void;
setBlockTime: (blockTimeId: BlockTimeId) => void;
// VoIP updates
updateVoIP: (data: Partial<VoIPSelection>) => void;
setVoIPEnabled: (enabled: boolean) => void;
setVoIPTier: (tierId: VoIPTierId) => void;
setVoIPUserCount: (count: number) => void;
addHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
removeHardware: (hardwareId: string) => void;
updateHardwareQuantity: (hardwareId: string, quantity: number) => void;
// Web Hosting updates
updateWebHosting: (data: Partial<WebHostingSelection>) => void;
setWebHostingEnabled: (enabled: boolean) => void;
setWebHostingTier: (tierId: WebHostingTierId) => void;
// Email updates
updateEmail: (data: Partial<EmailSelection>) => void;
setEmailEnabled: (enabled: boolean) => void;
setEmailProvider: (provider: EmailProvider) => void;
setEmailTier: (tierId: EmailTierId) => void;
setMailboxCount: (count: number) => void;
// Contact updates
updateContact: (data: Partial<ContactInfo>) => void;
setContactPreference: (preference: ContactPreference) => void;
setAgreedToTerms: (agreed: boolean) => void;
// Calculations
calculateQuote: () => QuoteResult;
getGPSMonthly: () => number;
getSupportMonthly: () => number;
getVoIPMonthly: () => number;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
getVoIPOneTime: () => number;
// Reset
resetQuote: () => void;
}
/**
* Quote calculation and state management hook
*/
export function useQuote(): UseQuoteReturn {
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
company,
gps,
support,
voip,
webHosting,
email,
contact,
}),
[company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// Company Updates
// ============================================================================
const updateCompany = useCallback((data: Partial<CompanyInfo>) => {
setCompany((prev) => {
const updated = { ...prev, ...data };
// Sync endpoint count with GPS selection
if (data.endpointCount !== undefined) {
setGPS((gpsState) => ({ ...gpsState, endpointCount: data.endpointCount as number }));
}
return updated;
});
}, []);
const setEndpointCount = useCallback((count: number) => {
const validCount = Math.max(1, count);
setCompany((prev) => ({ ...prev, endpointCount: validCount }));
setGPS((prev) => ({ ...prev, endpointCount: validCount }));
}, []);
const setIndustry = useCallback((industry: Industry | '') => {
setCompany((prev) => ({ ...prev, industry }));
}, []);
// ============================================================================
// GPS Updates
// ============================================================================
const updateGPS = useCallback((data: Partial<GPSSelection>) => {
setGPS((prev) => ({ ...prev, ...data }));
}, []);
const setGPSTier = useCallback((tierId: GPSTierId) => {
setGPS((prev) => ({ ...prev, tierId }));
}, []);
const setEquipmentEnabled = useCallback((enabled: boolean) => {
setGPS((prev) => ({
...prev,
includeEquipment: enabled,
equipmentDeviceCount: enabled ? Math.max(prev.equipmentDeviceCount, 1) : 0,
}));
}, []);
const setEquipmentCount = useCallback((count: number) => {
setGPS((prev) => ({ ...prev, equipmentDeviceCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Support Updates
// ============================================================================
const updateSupport = useCallback((data: Partial<SupportSelection>) => {
setSupport((prev) => ({ ...prev, ...data }));
}, []);
const setSupportPlan = useCallback((planId: SupportPlanId) => {
setSupport((prev) => ({ ...prev, planId }));
}, []);
const setBlockTimeEnabled = useCallback((enabled: boolean) => {
setSupport((prev) => ({
...prev,
useBlockTime: enabled,
blockTimeId: enabled ? (prev.blockTimeId || 'block-10') : null,
}));
}, []);
const setBlockTime = useCallback((blockTimeId: BlockTimeId) => {
setSupport((prev) => ({ ...prev, blockTimeId, useBlockTime: true }));
}, []);
// ============================================================================
// VoIP Updates
// ============================================================================
const updateVoIP = useCallback((data: Partial<VoIPSelection>) => {
setVoIP((prev) => ({ ...prev, ...data }));
}, []);
const setVoIPEnabled = useCallback((enabled: boolean) => {
setVoIP((prev) => ({
...prev,
enabled,
userCount: enabled ? Math.max(prev.userCount, 1) : 0,
}));
}, []);
const setVoIPTier = useCallback((tierId: VoIPTierId) => {
setVoIP((prev) => ({ ...prev, tierId }));
}, []);
const setVoIPUserCount = useCallback((count: number) => {
setVoIP((prev) => ({ ...prev, userCount: Math.max(0, count) }));
}, []);
const addHardware = useCallback((hardwareId: string, quantity: number, isRental: boolean) => {
setVoIP((prev) => {
const existing = prev.hardware.find((h) => h.hardwareId === hardwareId);
if (existing) {
return {
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity, isRental } : h
),
};
}
return {
...prev,
hardware: [...prev.hardware, { hardwareId, quantity, isRental }],
};
});
}, []);
const removeHardware = useCallback((hardwareId: string) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.filter((h) => h.hardwareId !== hardwareId),
}));
}, []);
const updateHardwareQuantity = useCallback((hardwareId: string, quantity: number) => {
setVoIP((prev) => ({
...prev,
hardware: prev.hardware.map((h) =>
h.hardwareId === hardwareId ? { ...h, quantity: Math.max(0, quantity) } : h
),
}));
}, []);
// ============================================================================
// Web Hosting Updates
// ============================================================================
const updateWebHosting = useCallback((data: Partial<WebHostingSelection>) => {
setWebHosting((prev) => ({ ...prev, ...data }));
}, []);
const setWebHostingEnabled = useCallback((enabled: boolean) => {
setWebHosting((prev) => ({ ...prev, enabled }));
}, []);
const setWebHostingTier = useCallback((tierId: WebHostingTierId) => {
setWebHosting((prev) => ({ ...prev, tierId }));
}, []);
// ============================================================================
// Email Updates
// ============================================================================
const updateEmail = useCallback((data: Partial<EmailSelection>) => {
setEmail((prev) => ({ ...prev, ...data }));
}, []);
const setEmailEnabled = useCallback((enabled: boolean) => {
setEmail((prev) => ({
...prev,
enabled,
mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0,
}));
}, []);
const setEmailProvider = useCallback((provider: EmailProvider) => {
setEmail((prev) => {
// Set default tier for provider
const defaultTier = provider === 'm365' ? 'm365-standard' : 'whm-standard';
return { ...prev, provider, tierId: defaultTier as EmailTierId };
});
}, []);
const setEmailTier = useCallback((tierId: EmailTierId) => {
setEmail((prev) => ({ ...prev, tierId }));
}, []);
const setMailboxCount = useCallback((count: number) => {
setEmail((prev) => ({ ...prev, mailboxCount: Math.max(0, count) }));
}, []);
// ============================================================================
// Contact Updates
// ============================================================================
const updateContact = useCallback((data: Partial<ContactInfo>) => {
setContact((prev) => ({ ...prev, ...data }));
}, []);
const setContactPreference = useCallback((preference: ContactPreference) => {
setContact((prev) => ({ ...prev, contactPreference: preference }));
}, []);
const setAgreedToTerms = useCallback((agreed: boolean) => {
setContact((prev) => ({ ...prev, agreedToTerms: agreed }));
}, []);
// ============================================================================
// Calculation Functions
// ============================================================================
const getGPSMonthly = useCallback((): number => {
const tier = gpsTiers.find((t) => t.id === gps.tierId);
if (!tier) return 0;
let total = tier.pricePerEndpoint * gps.endpointCount;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
total += equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
return total;
}, [gps]);
const getSupportMonthly = useCallback((): number => {
const plan = supportPlans.find((p) => p.id === support.planId);
if (!plan) return 0;
let total = plan.monthlyPrice;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
total += blockTime.price;
}
}
return total;
}, [support]);
const getVoIPMonthly = useCallback((): number => {
if (!voip.enabled) return 0;
const tier = voipTiers.find((t) => t.id === voip.tierId);
if (!tier) return 0;
let total = tier.pricePerUser * voip.userCount;
// Add rental hardware costs
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.monthlyRental * hw.quantity;
}
}
});
return total;
}, [voip]);
const getVoIPOneTime = useCallback((): number => {
if (!voip.enabled) return 0;
let total = 0;
// Add purchased hardware costs
voip.hardware.forEach((hw) => {
if (!hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
total += hardware.oneTimePrice * hw.quantity;
}
}
});
return total;
}, [voip]);
const getWebHostingMonthly = useCallback((): number => {
if (!webHosting.enabled) return 0;
const tier = webHostingTiers.find((t) => t.id === webHosting.tierId);
return tier ? tier.monthlyPrice : 0;
}, [webHosting]);
const getEmailMonthly = useCallback((): number => {
if (!email.enabled) return 0;
const tier = emailTiers.find((t) => t.id === email.tierId);
return tier ? tier.pricePerMailbox * email.mailboxCount : 0;
}, [email]);
const calculateQuote = useCallback((): QuoteResult => {
const gpsMonthly = getGPSMonthly();
const supportMonthly = getSupportMonthly();
const voipMonthly = getVoIPMonthly();
const voipOneTime = getVoIPOneTime();
const webHostingMonthly = getWebHostingMonthly();
const emailMonthly = getEmailMonthly();
// Calculate GPS breakdown
const gpsTier = gpsTiers.find((t) => t.id === gps.tierId);
const gpsMonitoring = gpsTier ? gpsTier.pricePerEndpoint * gps.endpointCount : 0;
let gpsEquipment = 0;
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
gpsEquipment = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
}
// Calculate support breakdown
const supportPlan = supportPlans.find((p) => p.id === support.planId);
const supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
let supportBlockTime = 0;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
supportBlockTime = blockTime.price;
}
}
// Calculate VoIP breakdown
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
const voipService = voip.enabled && voipTier ? voipTier.pricePerUser * voip.userCount : 0;
let voipHardwareMonthly = 0;
if (voip.enabled) {
voip.hardware.forEach((hw) => {
if (hw.isRental) {
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
if (hardware) {
voipHardwareMonthly += hardware.monthlyRental * hw.quantity;
}
}
});
}
const breakdown: QuoteBreakdown = {
gps: {
monitoring: gpsMonitoring,
equipment: gpsEquipment,
total: gpsMonthly,
},
support: {
plan: supportPlanCost,
blockTime: supportBlockTime,
total: supportMonthly,
},
voip: {
service: voipService,
hardware: voipHardwareMonthly,
total: voipMonthly,
},
webHosting: webHostingMonthly,
email: emailMonthly,
};
const monthlyTotal = gpsMonthly + supportMonthly + voipMonthly + webHostingMonthly + emailMonthly;
const result: QuoteResult = {
monthlyTotal,
oneTimeTotal: voipOneTime,
breakdown,
gpsMonthly,
supportMonthly,
voipMonthly,
webHostingMonthly,
emailMonthly,
};
setQuoteResult(result);
return result;
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
setVoIP(initialVoIPSelection);
setWebHosting(initialWebHostingSelection);
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
}, []);
return {
quoteData,
quoteResult,
// Company updates
updateCompany,
setEndpointCount,
setIndustry,
// GPS updates
updateGPS,
setGPSTier,
setEquipmentEnabled,
setEquipmentCount,
// Support updates
updateSupport,
setSupportPlan,
setBlockTimeEnabled,
setBlockTime,
// VoIP updates
updateVoIP,
setVoIPEnabled,
setVoIPTier,
setVoIPUserCount,
addHardware,
removeHardware,
updateHardwareQuantity,
// Web Hosting updates
updateWebHosting,
setWebHostingEnabled,
setWebHostingTier,
// Email updates
updateEmail,
setEmailEnabled,
setEmailProvider,
setEmailTier,
setMailboxCount,
// Contact updates
updateContact,
setContactPreference,
setAgreedToTerms,
// Calculations
calculateQuote,
getGPSMonthly,
getSupportMonthly,
getVoIPMonthly,
getWebHostingMonthly,
getEmailMonthly,
getVoIPOneTime,
// Reset
resetQuote,
};
}

View File

@@ -0,0 +1,160 @@
import { useState, useCallback, useMemo } from 'react';
import type { WizardStep } from '@/types/quote';
/**
* Wizard steps configuration for the 7-step MSP Quote Wizard
*/
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
{
id: 'company',
title: 'Company Profile',
description: 'Tell us about your business',
},
{
id: 'gps',
title: 'GPS Monitoring',
description: 'Select your monitoring tier',
},
{
id: 'support',
title: 'Support Plan',
description: 'Choose your support level',
},
{
id: 'voip',
title: 'VoIP Phone System',
description: 'Business phone options',
},
{
id: 'web-email',
title: 'Web & Email',
description: 'Hosting and email services',
},
{
id: 'summary',
title: 'Review Quote',
description: 'Review your selections',
},
{
id: 'contact',
title: 'Get Your Quote',
description: 'Submit your information',
},
];
export interface UseWizardReturn {
currentStep: number;
steps: WizardStep[];
totalSteps: number;
isFirstStep: boolean;
isLastStep: boolean;
goToStep: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
markStepComplete: (stepIndex: number) => void;
markStepIncomplete: (stepIndex: number) => void;
resetWizard: () => void;
progress: number;
canProceed: boolean;
setCanProceed: (canProceed: boolean) => void;
currentStepId: string;
getStepByIndex: (index: number) => WizardStep | undefined;
}
export function useWizard(): UseWizardReturn {
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [canProceed, setCanProceed] = useState(true);
const totalSteps = WIZARD_STEPS.length;
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
const steps: WizardStep[] = useMemo(() => {
return WIZARD_STEPS.map((step, index) => ({
...step,
isComplete: completedSteps.has(index),
isActive: index === currentStep,
}));
}, [currentStep, completedSteps]);
const currentStepId = useMemo(() => {
return WIZARD_STEPS[currentStep]?.id || '';
}, [currentStep]);
const progress = useMemo(() => {
// Progress based on current step position (0 to 100)
return Math.round((currentStep / (totalSteps - 1)) * 100);
}, [currentStep, totalSteps]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
// Allow going back to any previous step
// Only allow going forward to completed steps or the next step
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
setCurrentStep(step);
}
}
},
[totalSteps, currentStep, completedSteps]
);
const nextStep = useCallback(() => {
if (!isLastStep && canProceed) {
// Mark current step as complete when moving forward
setCompletedSteps((prev) => new Set(prev).add(currentStep));
setCurrentStep((prev) => prev + 1);
}
}, [currentStep, isLastStep, canProceed]);
const prevStep = useCallback(() => {
if (!isFirstStep) {
setCurrentStep((prev) => prev - 1);
}
}, [isFirstStep]);
const markStepComplete = useCallback((stepIndex: number) => {
setCompletedSteps((prev) => new Set(prev).add(stepIndex));
}, []);
const markStepIncomplete = useCallback((stepIndex: number) => {
setCompletedSteps((prev) => {
const newSet = new Set(prev);
newSet.delete(stepIndex);
return newSet;
});
}, []);
const resetWizard = useCallback(() => {
setCurrentStep(0);
setCompletedSteps(new Set());
setCanProceed(true);
}, []);
const getStepByIndex = useCallback(
(index: number): WizardStep | undefined => {
return steps[index];
},
[steps]
);
return {
currentStep,
steps,
totalSteps,
isFirstStep,
isLastStep,
goToStep,
nextStep,
prevStep,
markStepComplete,
markStepIncomplete,
resetWizard,
progress,
canProceed,
setCanProceed,
currentStepId,
getStepByIndex,
};
}

View File

@@ -0,0 +1,62 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary: #333d49;
--color-accent: #fe7400;
--color-navy: #113559;
--color-gray-600: #4d4d4d;
--font-family-lexend: 'Lexend', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Lexend', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Lexend', sans-serif;
background-color: #ffffff;
color: #333d49;
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #333d49;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #113559;
}
/* Focus styles for accessibility */
:focus-visible {
outline: 2px solid #fe7400;
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: #fe7400;
color: #ffffff;
}

View File

@@ -0,0 +1,84 @@
import axios from 'axios';
import type { QuoteData, QuoteResult } from '@/types/quote';
/**
* API client for MSP Quote Wizard
*/
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
});
// Request interceptor for adding auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('quote_wizard_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('quote_wizard_token');
}
return Promise.reject(error);
}
);
/**
* API endpoints
*/
export const quoteApi = {
/**
* Calculate quote based on provided data
*/
calculateQuote: async (data: QuoteData): Promise<QuoteResult> => {
const response = await apiClient.post<QuoteResult>('/api/quotes/calculate', data);
return response.data;
},
/**
* Save quote for later retrieval
*/
saveQuote: async (data: QuoteData & { email: string }): Promise<{ quoteId: string }> => {
const response = await apiClient.post<{ quoteId: string }>('/api/quotes/save', data);
return response.data;
},
/**
* Retrieve saved quote by ID
*/
getQuote: async (quoteId: string): Promise<QuoteData & QuoteResult> => {
const response = await apiClient.get<QuoteData & QuoteResult>(`/api/quotes/${quoteId}`);
return response.data;
},
/**
* Submit quote request for sales follow-up
*/
submitQuoteRequest: async (data: QuoteData & {
contactInfo: {
name: string;
email: string;
phone?: string;
}
}): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/quotes/submit', data);
return response.data;
},
};

View File

@@ -0,0 +1,423 @@
import type {
GPSTier,
SupportPlan,
BlockTimeOption,
VoIPTier,
WebHostingTier,
EmailTier,
VoIPHardware
} from '@/types/quote';
/**
* GPS Monitoring Tiers
*/
export const gpsTiers: GPSTier[] = [
{
id: 'basic',
name: 'Basic',
description: 'Essential monitoring for small environments',
pricePerEndpoint: 19,
features: [
'Remote monitoring & management',
'8x5 help desk support',
'Patch management',
'Basic antivirus protection',
'Monthly health reports',
],
recommended: false,
},
{
id: 'pro',
name: 'Pro',
description: 'Comprehensive protection for growing businesses',
pricePerEndpoint: 26,
features: [
'Everything in Basic, plus:',
'24x7 help desk support',
'Advanced endpoint protection',
'Backup & disaster recovery',
'Network monitoring',
'Quarterly business reviews',
],
recommended: true,
},
{
id: 'advanced',
name: 'Advanced',
description: 'Enterprise-grade security and compliance',
pricePerEndpoint: 39,
features: [
'Everything in Pro, plus:',
'Dedicated account manager',
'Virtual CIO services',
'Compliance management',
'Security awareness training',
'Advanced threat detection',
'Priority response SLA',
],
recommended: false,
},
];
/**
* Equipment monitoring pricing
*/
export const equipmentMonitoring = {
basePrice: 25, // Up to 10 devices
baseDevices: 10,
additionalDevicePrice: 3, // Per additional device
};
/**
* Support Plans
*/
export const supportPlans: SupportPlan[] = [
{
id: 'essential',
name: 'Essential',
description: 'Basic support for small teams',
monthlyPrice: 200,
includedHours: 2,
effectiveHourlyRate: 100,
recommended: false,
},
{
id: 'standard',
name: 'Standard',
description: 'Balanced support for growing businesses',
monthlyPrice: 380,
includedHours: 4,
effectiveHourlyRate: 95,
recommended: true,
},
{
id: 'premium',
name: 'Premium',
description: 'Enhanced support with faster response',
monthlyPrice: 540,
includedHours: 6,
effectiveHourlyRate: 90,
recommended: false,
},
{
id: 'priority',
name: 'Priority',
description: 'Top-tier support with dedicated resources',
monthlyPrice: 850,
includedHours: 10,
effectiveHourlyRate: 85,
recommended: false,
},
];
/**
* Block Time Options
*/
export const blockTimeOptions: BlockTimeOption[] = [
{
id: 'block-10',
hours: 10,
price: 1500,
effectiveHourlyRate: 150,
},
{
id: 'block-20',
hours: 20,
price: 2600,
effectiveHourlyRate: 130,
},
{
id: 'block-30',
hours: 30,
price: 3000,
effectiveHourlyRate: 100,
},
];
/**
* VoIP Tiers
*/
export const voipTiers: VoIPTier[] = [
{
id: 'voip-basic',
name: 'Basic',
description: 'Essential phone features for small teams',
pricePerUser: 22,
features: [
'Unlimited local & long distance',
'Voicemail to email',
'Basic auto-attendant',
'Mobile app',
],
recommended: false,
},
{
id: 'voip-standard',
name: 'Standard',
description: 'Full-featured business phone system',
pricePerUser: 28,
features: [
'Everything in Basic, plus:',
'Video conferencing',
'Ring groups',
'Call recording',
'CRM integration',
],
recommended: true,
},
{
id: 'voip-pro',
name: 'Pro',
description: 'Advanced features for power users',
pricePerUser: 35,
features: [
'Everything in Standard, plus:',
'Advanced analytics',
'Custom IVR',
'Supervisor dashboard',
'API access',
],
recommended: false,
},
{
id: 'voip-callcenter',
name: 'Call Center',
description: 'Full call center capabilities',
pricePerUser: 55,
features: [
'Everything in Pro, plus:',
'Queue management',
'Wallboards',
'Agent scoring',
'Predictive dialing',
'Real-time monitoring',
],
recommended: false,
},
];
/**
* VoIP Hardware Options
*/
export const voipHardware: VoIPHardware[] = [
{
id: 'yealink-t33g',
name: 'Yealink T33G',
description: 'Entry-level IP phone',
oneTimePrice: 89,
monthlyRental: 5,
},
{
id: 'yealink-t54w',
name: 'Yealink T54W',
description: 'Mid-range color screen phone',
oneTimePrice: 169,
monthlyRental: 8,
},
{
id: 'yealink-t58a',
name: 'Yealink T58A',
description: 'Executive phone with video',
oneTimePrice: 299,
monthlyRental: 12,
},
{
id: 'headset-basic',
name: 'USB Headset',
description: 'Basic USB headset',
oneTimePrice: 45,
monthlyRental: 3,
},
{
id: 'headset-wireless',
name: 'Wireless Headset',
description: 'Premium wireless headset',
oneTimePrice: 149,
monthlyRental: 7,
},
];
/**
* Web Hosting Tiers
*/
export const webHostingTiers: WebHostingTier[] = [
{
id: 'hosting-starter',
name: 'Starter',
description: 'Perfect for simple business sites',
monthlyPrice: 15,
storage: '5GB',
sites: 1,
features: [
'5GB SSD storage',
'1 website',
'Free SSL certificate',
'Daily backups',
'Email support',
],
recommended: false,
},
{
id: 'hosting-business',
name: 'Business',
description: 'Great for multiple sites and more traffic',
monthlyPrice: 35,
storage: '25GB',
sites: 5,
features: [
'25GB SSD storage',
'5 websites',
'Free SSL certificates',
'Daily backups',
'Staging environment',
'Priority support',
],
recommended: true,
},
{
id: 'hosting-commerce',
name: 'Commerce',
description: 'E-commerce ready with unlimited sites',
monthlyPrice: 65,
storage: '50GB',
sites: -1, // Unlimited
features: [
'50GB SSD storage',
'Unlimited websites',
'Free SSL certificates',
'Real-time backups',
'CDN included',
'PCI compliance',
'Dedicated support',
],
recommended: false,
},
];
/**
* Email Tiers
*/
export const emailTiers: EmailTier[] = [
// WHM (Self-hosted) Options
{
id: 'whm-basic',
name: 'WHM Basic',
description: 'Self-hosted email basics',
pricePerMailbox: 2,
provider: 'whm',
storage: '5GB',
features: [
'5GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Spam filtering',
],
recommended: false,
},
{
id: 'whm-standard',
name: 'WHM Standard',
description: 'Enhanced self-hosted email',
pricePerMailbox: 4,
provider: 'whm',
storage: '10GB',
features: [
'10GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Advanced spam filtering',
'Email aliases',
],
recommended: false,
},
{
id: 'whm-pro',
name: 'WHM Pro',
description: 'Professional self-hosted email',
pricePerMailbox: 10,
provider: 'whm',
storage: '25GB',
features: [
'25GB storage per mailbox',
'Webmail access',
'IMAP/POP3/SMTP',
'Premium spam filtering',
'Email archiving',
'Shared calendars',
],
recommended: false,
},
// Microsoft 365 Options
{
id: 'm365-basic',
name: 'M365 Basic',
description: 'Microsoft 365 essentials',
pricePerMailbox: 7,
provider: 'm365',
storage: '50GB',
features: [
'50GB mailbox',
'Outlook web access',
'Mobile apps',
'OneDrive 1TB',
'Microsoft Teams',
],
recommended: false,
},
{
id: 'm365-standard',
name: 'M365 Standard',
description: 'Full Microsoft 365 experience',
pricePerMailbox: 14,
provider: 'm365',
storage: '50GB',
features: [
'50GB mailbox',
'Desktop Office apps',
'OneDrive 1TB',
'Microsoft Teams',
'SharePoint',
'Bookings',
],
recommended: true,
},
{
id: 'm365-premium',
name: 'M365 Premium',
description: 'Enterprise security and compliance',
pricePerMailbox: 24,
provider: 'm365',
storage: '100GB',
features: [
'100GB mailbox',
'Everything in Standard',
'Advanced security',
'Device management',
'Azure AD Premium',
'Data loss prevention',
],
recommended: false,
},
];
/**
* Industry options for company info
*/
export const industries = [
'Healthcare',
'Legal',
'Finance',
'Manufacturing',
'Retail',
'Professional Services',
'Other',
] as const;
/**
* Contact preference options
*/
export const contactPreferences = [
{ id: 'email', label: 'Email' },
{ id: 'phone', label: 'Phone' },
{ id: 'either', label: 'Either' },
] as const;

View File

@@ -0,0 +1,69 @@
import { type ClassValue, clsx } from 'clsx';
/**
* Utility function to merge class names
* Combines clsx for conditional classes
*/
export function cn(...inputs: ClassValue[]): string {
return clsx(inputs);
}
/**
* Format currency value
*/
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
/**
* Format number with commas
*/
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
/**
* Calculate total device count
*/
export function getTotalDevices(devices: {
workstations: number;
laptops: number;
servers: number;
networkDevices: number;
mobileDevices: number;
}): number {
return (
devices.workstations +
devices.laptops +
devices.servers +
devices.networkDevices +
devices.mobileDevices
);
}

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,269 @@
/**
* MSP Quote Wizard Types
*/
// ============================================================================
// GPS Monitoring Types
// ============================================================================
export type GPSTierId = 'basic' | 'pro' | 'advanced';
export interface GPSTier {
id: GPSTierId;
name: string;
description: string;
pricePerEndpoint: number;
features: string[];
recommended: boolean;
}
export interface GPSSelection {
tierId: GPSTierId;
endpointCount: number;
includeEquipment: boolean;
equipmentDeviceCount: number;
}
// ============================================================================
// Support Plan Types
// ============================================================================
export type SupportPlanId = 'essential' | 'standard' | 'premium' | 'priority';
export type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
export interface SupportPlan {
id: SupportPlanId;
name: string;
description: string;
monthlyPrice: number;
includedHours: number;
effectiveHourlyRate: number;
recommended: boolean;
}
export interface BlockTimeOption {
id: BlockTimeId;
hours: number;
price: number;
effectiveHourlyRate: number;
}
export interface SupportSelection {
planId: SupportPlanId;
useBlockTime: boolean;
blockTimeId: BlockTimeId | null;
}
// ============================================================================
// VoIP Types
// ============================================================================
export type VoIPTierId = 'voip-basic' | 'voip-standard' | 'voip-pro' | 'voip-callcenter';
export interface VoIPTier {
id: VoIPTierId;
name: string;
description: string;
pricePerUser: number;
features: string[];
recommended: boolean;
}
export interface VoIPHardware {
id: string;
name: string;
description: string;
oneTimePrice: number;
monthlyRental: number;
}
export interface HardwareSelection {
hardwareId: string;
quantity: number;
isRental: boolean;
}
export interface VoIPSelection {
enabled: boolean;
tierId: VoIPTierId;
userCount: number;
hardware: HardwareSelection[];
}
// ============================================================================
// Web Hosting Types
// ============================================================================
export type WebHostingTierId = 'hosting-starter' | 'hosting-business' | 'hosting-commerce';
export interface WebHostingTier {
id: WebHostingTierId;
name: string;
description: string;
monthlyPrice: number;
storage: string;
sites: number; // -1 = unlimited
features: string[];
recommended: boolean;
}
export interface WebHostingSelection {
enabled: boolean;
tierId: WebHostingTierId;
}
// ============================================================================
// Email Types
// ============================================================================
export type EmailProvider = 'whm' | 'm365';
export type EmailTierId = 'whm-basic' | 'whm-standard' | 'whm-pro' | 'm365-basic' | 'm365-standard' | 'm365-premium';
export interface EmailTier {
id: EmailTierId;
name: string;
description: string;
pricePerMailbox: number;
provider: EmailProvider;
storage: string;
features: string[];
recommended: boolean;
}
export interface EmailSelection {
enabled: boolean;
provider: EmailProvider;
tierId: EmailTierId;
mailboxCount: number;
}
// ============================================================================
// Company & Contact Types
// ============================================================================
export type Industry =
| 'Healthcare'
| 'Legal'
| 'Finance'
| 'Manufacturing'
| 'Retail'
| 'Professional Services'
| 'Other';
export type ContactPreference = 'email' | 'phone' | 'either';
export interface CompanyInfo {
name: string;
endpointCount: number;
industry: Industry | '';
notes: string;
}
export interface ContactInfo {
name: string;
email: string;
phone: string;
companyName: string;
currentITSituation: string;
contactPreference: ContactPreference;
agreedToTerms: boolean;
}
// ============================================================================
// Quote Data & Result Types
// ============================================================================
export interface QuoteData {
company: CompanyInfo;
gps: GPSSelection;
support: SupportSelection;
voip: VoIPSelection;
webHosting: WebHostingSelection;
email: EmailSelection;
contact: ContactInfo;
}
export interface QuoteBreakdown {
gps: {
monitoring: number;
equipment: number;
total: number;
};
support: {
plan: number;
blockTime: number;
total: number;
};
voip: {
service: number;
hardware: number;
total: number;
};
webHosting: number;
email: number;
}
export interface QuoteResult {
monthlyTotal: number;
oneTimeTotal: number;
breakdown: QuoteBreakdown;
gpsMonthly: number;
supportMonthly: number;
voipMonthly: number;
webHostingMonthly: number;
emailMonthly: number;
}
// ============================================================================
// Wizard Types
// ============================================================================
export interface WizardStep {
id: string;
title: string;
description: string;
isComplete: boolean;
isActive: boolean;
}
export interface StepValidation {
isValid: boolean;
errors: string[];
}
// ============================================================================
// Legacy Types (for backward compatibility)
// ============================================================================
export type ServiceTier = 'essential' | 'professional' | 'enterprise';
export interface DeviceCount {
workstations: number;
laptops: number;
servers: number;
networkDevices: number;
mobileDevices: number;
}
export interface ServiceSelection {
tier: ServiceTier;
addOns: string[];
}
export interface PricingTier {
id: ServiceTier;
name: string;
description: string;
basePrice: number;
perDevicePrice: number;
features: string[];
recommended?: boolean;
}
export interface AddOn {
id: string;
name: string;
description: string;
price: number;
priceType: 'flat' | 'per-device' | 'per-user';
}

View File

@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#333d49',
accent: '#fe7400',
navy: '#113559',
gray: {
DEFAULT: '#4d4d4d',
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4d4d4d',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
fontFamily: {
lexend: ['Lexend', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
},
})

View File

@@ -0,0 +1,10 @@
{
"status": "complete",
"version": 1,
"timestamp": "2026-03-09T12:00:00.000Z",
"files_written": [
"prompts/app_spec.txt",
"prompts/initializer_prompt.md"
],
"feature_count": 141
}

View File

@@ -0,0 +1,533 @@
<project_specification>
<project_name>MSP Quote Wizard</project_name>
<overview>
An interactive quotation wizard embedded on azcomputerguru.com that guides prospects through MSP service selection, generates proposals with pricing, and syncs leads to SyncroRMM. Features a 7-step linear wizard with expandable educational content, real-time price calculations, and admin dashboard for lead management.
</overview>
<technology_stack>
<frontend>
<framework>React 19 + TypeScript</framework>
<build_tool>Vite</build_tool>
<styling>Tailwind CSS v4 (GuruRMM glassmorphism design system)</styling>
<state_management>React Context + useReducer</state_management>
<api_client>Axios + React Query</api_client>
<animations>Framer Motion</animations>
<icons>Lucide React</icons>
</frontend>
<backend>
<runtime>Python 3.11+ (FastAPI)</runtime>
<database>MariaDB 10.6</database>
<orm>SQLAlchemy</orm>
<api_host>172.16.3.30:8001 (extend existing ClaudeTools API)</api_host>
</backend>
<communication>
<api>RESTful JSON API</api>
<auth>JWT for admin endpoints, token-based for public quote access</auth>
</communication>
<integrations>
<crm>SyncroRMM API (https://computerguru.syncromsp.com/api/v1)</crm>
<email>SMTP or SendGrid for notifications</email>
<pdf>WeasyPrint or Puppeteer for quote generation</pdf>
</integrations>
</technology_stack>
<prerequisites>
<environment_setup>
- Node.js 20+ for frontend development
- Python 3.11+ for backend
- Access to MariaDB at 172.16.3.30:3306
- SyncroRMM API credentials
- SMTP credentials for email notifications
</environment_setup>
</prerequisites>
<feature_count>141</feature_count>
<security_and_access_control>
<user_roles>
<role name="public">
<permissions>
- Can create and view their own quotes via access token
- Can submit quotes with contact information
- Can view/download PDF of their quote
- Cannot access admin endpoints
</permissions>
<protected_routes>
- /api/quotes/* (public with token)
</protected_routes>
</role>
<role name="admin">
<permissions>
- Can view all quotes
- Can update quote status
- Can view analytics/stats
- Can manually sync to SyncroRMM
- Can configure notification settings
</permissions>
<protected_routes>
- /api/admin/* (JWT required)
- /admin/* pages in GuruRMM dashboard
</protected_routes>
</role>
</user_roles>
<authentication>
<method>Token-based for public quotes, JWT for admin</method>
<session_timeout>24 hours for quote tokens, standard JWT for admin</session_timeout>
<quote_expiry>30 days after creation</quote_expiry>
</authentication>
<sensitive_operations>
- Quote submission triggers SyncroRMM sync
- Admin status changes are logged
- Email/phone validation before sync
</sensitive_operations>
<seo>
- noindex, nofollow meta tags on quote wizard
- X-Robots-Tag header on hosting server
</seo>
</security_and_access_control>
<core_features>
<wizard_navigation>
- Progress bar showing current step and completion
- Step indicators with clickable navigation (for completed steps)
- Next/Back buttons with validation
- Step transition animations
- Auto-save draft on step change
- Resume incomplete quote via token
- Mobile-responsive step layout
</wizard_navigation>
<step_1_company_profile>
- Company name input (optional)
- Number of endpoints/employees input
- Industry dropdown selection
- "What brings you here today?" textarea (optional)
- Form validation with helpful messages
- Auto-create quote draft on entry
</step_1_company_profile>
<step_2_gps_monitoring>
- Three tier pricing cards (Basic $19, Pro $26, Advanced $39)
- Expandable feature descriptions for each tier
- Quantity input tied to endpoint count from Step 1
- Equipment monitoring add-on toggle ($25/mo base + $3/device)
- Real-time price calculation display
- Tier comparison table (expandable)
- Recommended tier highlight based on company size
</step_2_gps_monitoring>
<step_3_support_plan>
- Four tier pricing cards (Essential $200, Standard $380, Premium $540, Priority $850)
- Included hours and response time display
- Effective hourly rate calculation
- Prepaid block time option (10hr/$1500, 20hr/$2600, 30hr/$3000)
- Expandable details for each tier
- Recommendation based on endpoint count
</step_3_support_plan>
<step_4_voip>
- Toggle: "Need business phones?"
- Skip step if toggle is off
- Four VoIP tier cards (Basic $22, Standard $28, Pro $35, CallCenter $55)
- User count input
- Hardware options with quantity selectors
- Basic Desk Phone (T53W) $219
- Business Desk Phone (T54W) $279
- Executive Phone (T57W) $359
- Conference Phone (CP920) $599
- Wireless Headset (WH62) $159
- Cordless Phone (W73P) $199
- Add-on services (DID, toll-free, SMS, fax, Teams)
- Real-time total calculation
</step_4_voip>
<step_5_web_email>
- Web hosting toggle with tier selection
- Starter $15 (5GB, 1 site)
- Business $35 (25GB, 5 sites)
- Commerce $65 (50GB, unlimited)
- Email provider choice (expandable comparison)
- WHM Email ($2-20/mailbox based on storage)
- Microsoft 365 Basic $7, Standard $14, Premium $24
- Exchange Online $5
- Email user count input
- Add-ons: email security $3/mailbox, dedicated IP $5, SSL $6.25
</step_5_web_email>
<step_6_summary>
- Itemized breakdown by category
- Monthly recurring total (prominent display)
- One-time/setup costs (separate section)
- Edit buttons to revisit any step
- Collapsible category sections
- Savings highlight if applicable
- Print-friendly view option
</step_6_summary>
<step_7_contact>
- Contact name (required)
- Email address (required, validated)
- Phone number (recommended, formatted)
- Company name (pre-filled from Step 1)
- Current IT situation textarea
- Preferred contact method selection
- Terms acceptance checkbox
- Submit button with loading state
- Duplicate email check against SyncroRMM
- Success confirmation with quote reference
</step_7_contact>
<expandable_info>
- Collapsible info cards throughout wizard
- "Learn more" buttons for each feature
- Smooth expand/collapse animations
- Feature definitions in plain language
- Use case examples
- Comparison tables within expandables
</expandable_info>
<pricing_calculations>
- Real-time total updates as selections change
- Category subtotals
- One-time vs recurring separation
- Quantity-based calculations
- Add-on aggregation
- Discount display (if applicable)
</pricing_calculations>
<quote_api_public>
- POST /api/quotes - Create new quote (returns access_token)
- GET /api/quotes/{token} - Get quote by access token
- PUT /api/quotes/{token} - Update quote (wizard progress)
- POST /api/quotes/{token}/submit - Finalize and submit
- GET /api/quotes/{token}/pdf - Generate PDF
- Rate limiting for public endpoints
</quote_api_public>
<quote_api_admin>
- GET /api/admin/quotes - List all quotes (paginated, filterable)
- GET /api/admin/quotes/{id} - Get quote details
- PUT /api/admin/quotes/{id} - Update status, add notes
- GET /api/admin/quotes/stats - Dashboard analytics
- POST /api/admin/quotes/{id}/sync-syncro - Manual sync
</quote_api_admin>
<syncro_integration>
- Duplicate check via GET /customers?email={email}
- Lead creation via POST /leads
- Quote details in ticket_description
- Sync status tracking
- Error handling for API failures
- Manual retry capability
</syncro_integration>
<notifications>
- Customer confirmation email with quote link
- Admin alert email on new submission
- Email templates with branding
- Quote PDF attachment option
- Webhook support for automation
</notifications>
<admin_dashboard>
- Quote listing with filters (status, date, value)
- Search by company/contact/email
- Quote detail view with full breakdown
- Activity timeline per quote
- Status management (draft, submitted, followed_up, converted)
- SyncroRMM sync status indicator
- Basic analytics (conversion funnel, popular services)
</admin_dashboard>
<pdf_generation>
- Professional quote document
- Company branding (logo, colors)
- Itemized service breakdown
- Terms and conditions
- Validity period display
- Contact information
</pdf_generation>
</core_features>
<database_schema>
<tables>
<quotes>
- id (UUID, PK)
- company_name (VARCHAR 255, nullable)
- contact_name (VARCHAR 255, not null)
- contact_email (VARCHAR 255, not null)
- contact_phone (VARCHAR 50, nullable)
- employee_count (INT, nullable)
- industry (VARCHAR 100, nullable)
- current_it_situation (TEXT, nullable)
- status (ENUM: draft, submitted, viewed, followed_up, converted, expired)
- access_token (VARCHAR 64, unique, not null)
- monthly_total (DECIMAL 10,2)
- setup_total (DECIMAL 10,2)
- syncro_lead_id (VARCHAR 100, nullable)
- syncro_synced_at (DATETIME, nullable)
- is_existing_customer (BOOLEAN, default false)
- source (VARCHAR 50, default 'website')
- utm_source, utm_medium, utm_campaign (VARCHAR 100 each)
- ip_address (VARCHAR 45)
- user_agent (TEXT)
- created_at, updated_at, submitted_at, expires_at (DATETIME)
</quotes>
<quote_items>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- category (ENUM: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon)
- product_code (VARCHAR 50, not null)
- product_name (VARCHAR 255, not null)
- description (TEXT, nullable)
- quantity (INT, default 1)
- unit_price (DECIMAL 10,2, not null)
- setup_price (DECIMAL 10,2, default 0)
- billing_frequency (ENUM: monthly, yearly, one_time)
- tier (VARCHAR 50, nullable)
- is_recommended (BOOLEAN, default false)
- created_at (DATETIME)
</quote_items>
<quote_activity>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- action (VARCHAR 50, not null: created, step_completed, submitted, viewed, pdf_generated, synced_syncro, status_changed)
- step_name (VARCHAR 50, nullable)
- details (JSON, nullable)
- ip_address (VARCHAR 45, nullable)
- created_at (DATETIME)
</quote_activity>
<quote_notifications>
- id (UUID, PK)
- quote_id (UUID, FK to quotes, cascade delete)
- notification_type (ENUM: email, webhook)
- recipient (VARCHAR 255, not null)
- subject (VARCHAR 255, nullable)
- body (TEXT, nullable)
- status (ENUM: pending, sent, failed)
- attempts (INT, default 0)
- last_attempt_at, sent_at (DATETIME, nullable)
- error_message (TEXT, nullable)
- created_at (DATETIME)
</quote_notifications>
</tables>
</database_schema>
<api_endpoints_summary>
<public_quotes>
- POST /api/quotes (create quote, returns token)
- GET /api/quotes/{token} (get quote by token)
- PUT /api/quotes/{token} (update quote)
- POST /api/quotes/{token}/submit (finalize)
- GET /api/quotes/{token}/pdf (generate PDF)
</public_quotes>
<admin_quotes>
- GET /api/admin/quotes (list with filters)
- GET /api/admin/quotes/{id} (detail view)
- PUT /api/admin/quotes/{id} (update status/notes)
- GET /api/admin/quotes/stats (analytics)
- POST /api/admin/quotes/{id}/sync-syncro (manual sync)
</admin_quotes>
<syncro_proxy>
- GET /api/syncro/check-customer?email={email} (duplicate check)
</syncro_proxy>
</api_endpoints_summary>
<ui_layout>
<main_structure>
Full-width wizard container with centered content (max-width 1200px).
Progress bar at top showing 7 steps.
Main content area with current step.
Fixed bottom navigation (Back/Next buttons).
Running total display in corner/sidebar on desktop.
</main_structure>
<wizard_step_layout>
Step title with icon.
Optional subtitle/description.
Main content area (cards, forms, selections).
Expandable info sections.
Step-specific help text.
</wizard_step_layout>
<pricing_card_layout>
Card with tier name and price header.
Feature list with checkmarks.
"Most Popular" badge for recommended tier.
Select button at bottom.
Expandable "Learn more" section.
</pricing_card_layout>
<admin_layout>
Integrated into existing GuruRMM dashboard.
Left sidebar navigation (add "Quotes" menu item).
Main content area with quote listing.
Slide-out panel for quick view.
Full page for quote details.
</admin_layout>
</ui_layout>
<design_system>
<color_palette>
Match azcomputerguru.com website theme:
- Primary Dark: #333d49 (dark blue-gray)
- Accent Orange: #fe7400 (call-to-action, highlights)
- Navy: #113559 (headers, dark elements)
- White: #ffffff (backgrounds, text on dark)
- Black: #000000 (text)
- Gray: #4d4d4d (secondary text)
</color_palette>
<typography>
- Font Family: Lexend (Google Fonts) - same as main website
- Headings: Bold weight, navy or dark
- Body: Regular weight, gray/black
- Prices: Bold, larger size, orange accent (#fe7400)
</typography>
<effects>
- Clean, professional cards with subtle shadows
- Smooth transitions (200ms)
- Orange hover effects on buttons
- Progress bar with orange fill
- Step transition slides
- Consistent with main website aesthetic
</effects>
</design_system>
<implementation_steps>
<step number="1">
<title>Foundation - Database and API Setup</title>
<tasks>
- Create database migration for quote tables
- Build SQLAlchemy models (Quote, QuoteItem, QuoteActivity, QuoteNotification)
- Create Pydantic schemas for request/response
- Implement QuoteService with CRUD operations
- Build public quote endpoints (/api/quotes/*)
- Add token generation and validation
</tasks>
</step>
<step number="2">
<title>Frontend Project Setup</title>
<tasks>
- Initialize Vite + React + TypeScript project
- Configure Tailwind CSS v4 with GuruRMM design tokens
- Copy/adapt UI components from GuruRMM (Button, Card, Input)
- Set up React Router for wizard navigation
- Configure Axios + React Query for API calls
- Create pricing data constants from MSP pricing docs
</tasks>
</step>
<step number="3">
<title>Wizard Core Implementation</title>
<tasks>
- Build WizardContainer with progress tracking
- Implement WizardProgress component
- Create each step component (Steps 1-7)
- Build pricing card components
- Implement quantity selectors and toggles
- Wire up quote creation/update API calls
- Add form validation for each step
</tasks>
</step>
<step number="4">
<title>Educational Content and Polish</title>
<tasks>
- Build ExpandableInfo component
- Add feature descriptions and comparisons
- Implement tier comparison tables
- Add Framer Motion animations
- Ensure mobile responsiveness
- Add loading states and error handling
</tasks>
</step>
<step number="5">
<title>Integrations</title>
<tasks>
- Build SyncroService for API integration
- Implement duplicate customer check
- Create lead in SyncroRMM on submit
- Build NotificationService for emails
- Create email templates
- Implement PDF generation
</tasks>
</step>
<step number="6">
<title>Admin Dashboard</title>
<tasks>
- Add admin API endpoints
- Build quote listing page in GuruRMM
- Create quote detail view
- Implement filters and search
- Add status management
- Build basic analytics view
</tasks>
</step>
<step number="7">
<title>Deployment and Website Link</title>
<tasks>
- Build production frontend bundle
- Deploy to quote.azcomputerguru.com or ClaudeTools server
- Add noindex meta tags to quote wizard
- Configure CORS for API access
- Add "Get a Quote" button/link on azcomputerguru.com
- End-to-end testing
</tasks>
</step>
</implementation_steps>
<success_criteria>
<functionality>
- Complete wizard flow from start to submission
- All pricing calculations accurate
- Quote saved to database with all items
- SyncroRMM lead created on submission
- Email notifications sent
- PDF generation works
- Admin can view and manage all quotes
</functionality>
<user_experience>
- Wizard intuitive for non-technical users
- Expandable info provides education without cluttering
- Progress clearly visible at all times
- Mobile-friendly on all devices
- Fast loading and responsive interactions
</user_experience>
<technical_quality>
- No mock data - all real database operations
- Proper error handling throughout
- API validation on both client and server
- Secure token-based quote access
- Rate limiting on public endpoints
</technical_quality>
<design_polish>
- Matches GuruRMM design system
- Consistent glassmorphism styling
- Smooth animations and transitions
- Professional appearance suitable for business
</design_polish>
</success_criteria>
<pricing_data_reference>
<source_files>
- /projects/msp-pricing/docs/gps-pricing-structure.md
- /projects/msp-pricing/docs/voip-pricing-structure.md
- /projects/msp-pricing/docs/web-email-hosting-pricing.md
</source_files>
</pricing_data_reference>
</project_specification>

View File

@@ -0,0 +1,523 @@
## YOUR ROLE - INITIALIZER AGENT (Session 1 of Many)
You are the FIRST agent in a long-running autonomous development process.
Your job is to set up the foundation for all future coding agents.
### FIRST: Read the Project Specification
Start by reading `app_spec.txt` in your working directory. This file contains
the complete specification for what you need to build. Read it carefully
before proceeding.
---
## REQUIRED FEATURE COUNT
**CRITICAL:** You must create exactly **141** features using the `feature_create_bulk` tool.
This number was determined during spec creation and must be followed precisely. Do not create more or fewer features than specified.
---
### CRITICAL FIRST TASK: Create Features
Based on `app_spec.txt`, create features using the feature_create_bulk tool. The features are stored in a SQLite database,
which is the single source of truth for what needs to be built.
**Creating Features:**
Use the feature_create_bulk tool to add all features at once:
```
Use the feature_create_bulk tool with features=[
{
"category": "functional",
"name": "Brief feature name",
"description": "Brief description of the feature and what this test verifies",
"steps": [
"Step 1: Navigate to relevant page",
"Step 2: Perform action",
"Step 3: Verify expected result"
]
},
{
"category": "style",
"name": "Brief feature name",
"description": "Brief description of UI/UX requirement",
"steps": [
"Step 1: Navigate to page",
"Step 2: Take screenshot",
"Step 3: Verify visual requirements"
]
}
]
```
**Notes:**
- IDs and priorities are assigned automatically based on order
- All features start with `passes: false` by default
- You can create features in batches if there are many (e.g., 50 at a time)
**Requirements for features:**
- Feature count must match the `feature_count` specified in app_spec.txt
- Reference tiers for other projects:
- **Simple apps**: ~150 tests
- **Medium apps**: ~250 tests
- **Complex apps**: ~400+ tests
- Both "functional" and "style" categories
- Mix of narrow tests (2-5 steps) and comprehensive tests (10+ steps)
- At least 25 tests MUST have 10+ steps each (more for complex apps)
- Order features by priority: fundamental features first (the API assigns priority based on order)
- All features start with `passes: false` automatically
- Cover every feature in the spec exhaustively
- **MUST include tests from ALL 20 mandatory categories below**
---
## MANDATORY TEST CATEGORIES
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.
### Category Distribution by Complexity Tier
| Category | Simple | Medium | Complex |
| -------------------------------- | ------- | ------- | -------- |
| A. Security & Access Control | 5 | 20 | 40 |
| B. Navigation Integrity | 15 | 25 | 40 |
| C. Real Data Verification | 20 | 30 | 50 |
| D. Workflow Completeness | 10 | 20 | 40 |
| E. Error Handling | 10 | 15 | 25 |
| F. UI-Backend Integration | 10 | 20 | 35 |
| G. State & Persistence | 8 | 10 | 15 |
| H. URL & Direct Access | 5 | 10 | 20 |
| I. Double-Action & Idempotency | 5 | 8 | 15 |
| J. Data Cleanup & Cascade | 5 | 10 | 20 |
| K. Default & Reset | 5 | 8 | 12 |
| L. Search & Filter Edge Cases | 8 | 12 | 20 |
| M. Form Validation | 10 | 15 | 25 |
| N. Feedback & Notification | 8 | 10 | 15 |
| O. Responsive & Layout | 8 | 10 | 15 |
| P. Accessibility | 8 | 10 | 15 |
| Q. Temporal & Timezone | 5 | 8 | 12 |
| R. Concurrency & Race Conditions | 5 | 8 | 15 |
| S. Export/Import | 5 | 6 | 10 |
| T. Performance | 5 | 5 | 10 |
| **TOTAL** | **150** | **250** | **400+** |
---
### A. Security & Access Control Tests
Test that unauthorized access is blocked and permissions are enforced.
**Required tests (examples):**
- Unauthenticated user cannot access protected routes (redirect to login)
- Regular user cannot access admin-only pages (403 or redirect)
- API endpoints return 401 for unauthenticated requests
- API endpoints return 403 for unauthorized role access
- Session expires after configured inactivity period
- Logout clears all session data and tokens
- Invalid/expired tokens are rejected
- Each role can ONLY see their permitted menu items
- Direct URL access to unauthorized pages is blocked
- Sensitive operations require confirmation or re-authentication
- Cannot access another user's data by manipulating IDs in URL
- Password reset flow works securely
- Failed login attempts are handled (no information leakage)
### B. Navigation Integrity Tests
Test that every button, link, and menu item goes to the correct place.
**Required tests (examples):**
- Every button in sidebar navigates to correct page
- Every menu item links to existing route
- All CRUD action buttons (Edit, Delete, View) go to correct URLs with correct IDs
- Back button works correctly after each navigation
- Deep linking works (direct URL access to any page with auth)
- Breadcrumbs reflect actual navigation path
- 404 page shown for non-existent routes (not crash)
- After login, user redirected to intended destination (or dashboard)
- After logout, user redirected to login page
- Pagination links work and preserve current filters
- Tab navigation within pages works correctly
- Modal close buttons return to previous state
- Cancel buttons on forms return to previous page
### C. Real Data Verification Tests
Test that data is real (not mocked) and persists correctly.
**Required tests (examples):**
- Create a record via UI with unique content → verify it appears in list
- Create a record → refresh page → record still exists
- Create a record → log out → log in → record still exists
- Edit a record → verify changes persist after refresh
- Delete a record → verify it's gone from list AND database
- Delete a record → verify it's gone from related dropdowns
- Filter/search → results match actual data created in test
- Dashboard statistics reflect real record counts (create 3 items, count shows 3)
- Reports show real aggregated data
- Export functionality exports actual data you created
- Related records update when parent changes
- Timestamps are real and accurate (created_at, updated_at)
- Data created by User A is not visible to User B (unless shared)
- Empty state shows correctly when no data exists
### D. Workflow Completeness Tests
Test that every workflow can be completed end-to-end through the UI.
**Required tests (examples):**
- Every entity has working Create operation via UI form
- Every entity has working Read/View operation (detail page loads)
- Every entity has working Update operation (edit form saves)
- Every entity has working Delete operation (with confirmation dialog)
- Every status/state has a UI mechanism to transition to next state
- Multi-step processes (wizards) can be completed end-to-end
- Bulk operations (select all, delete selected) work
- Cancel/Undo operations work where applicable
- Required fields prevent submission when empty
- Form validation shows errors before submission
- Successful submission shows success feedback
- Backend workflow (e.g., user→customer conversion) has UI trigger
### E. Error Handling Tests
Test graceful handling of errors and edge cases.
**Required tests (examples):**
- Network failure shows user-friendly error message, not crash
- Invalid form input shows field-level errors
- API errors display meaningful messages to user
- 404 responses handled gracefully (show not found page)
- 500 responses don't expose stack traces or technical details
- Empty search results show "no results found" message
- Loading states shown during all async operations
- Timeout doesn't hang the UI indefinitely
- Submitting form with server error keeps user data in form
- File upload errors (too large, wrong type) show clear message
- Duplicate entry errors (e.g., email already exists) are clear
### F. UI-Backend Integration Tests
Test that frontend and backend communicate correctly.
**Required tests (examples):**
- Frontend request format matches what backend expects
- Backend response format matches what frontend parses
- All dropdown options come from real database data (not hardcoded)
- Related entity selectors (e.g., "choose category") populated from DB
- Changes in one area reflect in related areas after refresh
- Deleting parent handles children correctly (cascade or block)
- Filters work with actual data attributes from database
- Sort functionality sorts real data correctly
- Pagination returns correct page of real data
- API error responses are parsed and displayed correctly
- Loading spinners appear during API calls
- Optimistic updates (if used) rollback on failure
### G. State & Persistence Tests
Test that state is maintained correctly across sessions and tabs.
**Required tests (examples):**
- Refresh page mid-form - appropriate behavior (data kept or cleared)
- Close browser, reopen - session state handled correctly
- Same user in two browser tabs - changes sync or handled gracefully
- Browser back after form submit - no duplicate submission
- Bookmark a page, return later - works (with auth check)
- LocalStorage/cookies cleared - graceful re-authentication
- Unsaved changes warning when navigating away from dirty form
### H. URL & Direct Access Tests
Test direct URL access and URL manipulation security.
**Required tests (examples):**
- Change entity ID in URL - cannot access others' data
- Access /admin directly as regular user - blocked
- Malformed URL parameters - handled gracefully (no crash)
- Very long URL - handled correctly
- URL with SQL injection attempt - rejected/sanitized
- Deep link to deleted entity - shows "not found", not crash
- Query parameters for filters are reflected in UI
- Sharing a URL with filters preserves those filters
### I. Double-Action & Idempotency Tests
Test that rapid or duplicate actions don't cause issues.
**Required tests (examples):**
- Double-click submit button - only one record created
- Rapid multiple clicks on delete - only one deletion occurs
- Submit form, hit back, submit again - appropriate behavior
- Multiple simultaneous API calls - server handles correctly
- Refresh during save operation - data not corrupted
- Click same navigation link twice quickly - no issues
- Submit button disabled during processing
### J. Data Cleanup & Cascade Tests
Test that deleting data cleans up properly everywhere.
**Required tests (examples):**
- Delete parent entity - children removed from all views
- Delete item - removed from search results immediately
- Delete item - statistics/counts updated immediately
- Delete item - related dropdowns updated
- Delete item - cached views refreshed
- Soft delete (if applicable) - item hidden but recoverable
- Hard delete - item completely removed from database
### K. Default & Reset Tests
Test that defaults and reset functionality work correctly.
**Required tests (examples):**
- New form shows correct default values
- Date pickers default to sensible dates (today, not 1970)
- Dropdowns default to correct option (or placeholder)
- Reset button clears to defaults, not just empty
- Clear filters button resets all filters to default
- Pagination resets to page 1 when filters change
- Sorting resets when changing views
### L. Search & Filter Edge Cases
Test search and filter functionality thoroughly.
**Required tests (examples):**
- Empty search shows all results (or appropriate message)
- Search with only spaces - handled correctly
- Search with special characters (!@#$%^&\*) - no errors
- Search with quotes - handled correctly
- Search with very long string - handled correctly
- Filter combinations that return zero results - shows message
- Filter + search + sort together - all work correctly
- Filter persists after viewing detail and returning to list
- Clear individual filter - works correctly
- Search is case-insensitive (or clearly case-sensitive)
### M. Form Validation Tests
Test all form validation rules exhaustively.
**Required tests (examples):**
- Required field empty - shows error, blocks submit
- Email field with invalid email formats - shows error
- Password field - enforces complexity requirements
- Numeric field with letters - rejected
- Date field with invalid date - rejected
- Min/max length enforced on text fields
- Min/max values enforced on numeric fields
- Duplicate unique values rejected (e.g., duplicate email)
- Error messages are specific (not just "invalid")
- Errors clear when user fixes the issue
- Server-side validation matches client-side
- Whitespace-only input rejected for required fields
### N. Feedback & Notification Tests
Test that users get appropriate feedback for all actions.
**Required tests (examples):**
- Every successful save/create shows success feedback
- Every failed action shows error feedback
- Loading spinner during every async operation
- Disabled state on buttons during form submission
- Progress indicator for long operations (file upload)
- Toast/notification disappears after appropriate time
- Multiple notifications don't overlap incorrectly
- Success messages are specific (not just "Success")
### O. Responsive & Layout Tests
Test that the UI works on different screen sizes.
**Required tests (examples):**
- Desktop layout correct at 1920px width
- Tablet layout correct at 768px width
- Mobile layout correct at 375px width
- No horizontal scroll on any standard viewport
- Touch targets large enough on mobile (44px min)
- Modals fit within viewport on mobile
- Long text truncates or wraps correctly (no overflow)
- Tables scroll horizontally if needed on mobile
- Navigation collapses appropriately on mobile
### P. Accessibility Tests
Test basic accessibility compliance.
**Required tests (examples):**
- Tab navigation works through all interactive elements
- Focus ring visible on all focused elements
- Screen reader can navigate main content areas
- ARIA labels on icon-only buttons
- Color contrast meets WCAG AA (4.5:1 for text)
- No information conveyed by color alone
- Form fields have associated labels
- Error messages announced to screen readers
- Skip link to main content (if applicable)
- Images have alt text
### Q. Temporal & Timezone Tests
Test date/time handling.
**Required tests (examples):**
- Dates display in user's local timezone
- Created/updated timestamps accurate and formatted correctly
- Date picker allows only valid date ranges
- Overdue items identified correctly (timezone-aware)
- "Today", "This Week" filters work correctly for user's timezone
- Recurring items generate at correct times (if applicable)
- Date sorting works correctly across months/years
### R. Concurrency & Race Condition Tests
Test multi-user and race condition scenarios.
**Required tests (examples):**
- Two users edit same record - last save wins or conflict shown
- Record deleted while another user viewing - graceful handling
- List updates while user on page 2 - pagination still works
- Rapid navigation between pages - no stale data displayed
- API response arrives after user navigated away - no crash
- Concurrent form submissions from same user handled
### S. Export/Import Tests (if applicable)
Test data export and import functionality.
**Required tests (examples):**
- Export all data - file contains all records
- Export filtered data - only filtered records included
- Import valid file - all records created correctly
- Import duplicate data - handled correctly (skip/update/error)
- Import malformed file - error message, no partial import
- Export then import - data integrity preserved exactly
### T. Performance Tests
Test basic performance requirements.
**Required tests (examples):**
- Page loads in <3s with 100 records
- Page loads in <5s with 1000 records
- Search responds in <1s
- Infinite scroll doesn't degrade with many items
- Large file upload shows progress
- Memory doesn't leak on long sessions
- No console errors during normal operation
---
## ABSOLUTE PROHIBITION: NO MOCK DATA
The feature_list.json must include tests that **actively verify real data** and **detect mock data patterns**.
**Include these specific tests:**
1. Create unique test data (e.g., "TEST_12345_VERIFY_ME")
2. Verify that EXACT data appears in UI
3. Refresh page - data persists
4. Delete data - verify it's gone
5. If data appears that wasn't created during test - FLAG AS MOCK DATA
**The agent implementing features MUST NOT use:**
- Hardcoded arrays of fake data
- `mockData`, `fakeData`, `sampleData`, `dummyData` variables
- `// TODO: replace with real API`
- `setTimeout` simulating API delays with static data
- Static returns instead of database queries
---
**CRITICAL INSTRUCTION:**
IT IS CATASTROPHIC TO REMOVE OR EDIT FEATURES IN FUTURE SESSIONS.
Features can ONLY be marked as passing (via the `feature_mark_passing` tool with the feature_id).
Never remove features, never edit descriptions, never modify testing steps.
This ensures no functionality is missed.
### SECOND TASK: Create init.sh
Create a script called `init.sh` that future agents can use to quickly
set up and run the development environment. The script should:
1. Install any required dependencies
2. Start any necessary servers or services
3. Print helpful information about how to access the running application
Base the script on the technology stack specified in `app_spec.txt`.
### THIRD TASK: Initialize Git
Create a git repository and make your first commit with:
- init.sh (environment setup script)
- README.md (project overview and setup instructions)
- Any initial project structure files
Note: Features are stored in the SQLite database (features.db), not in a JSON file.
Commit message: "Initial setup: init.sh, project structure, and features created via API"
### FOURTH TASK: Create Project Structure
Set up the basic project structure based on what's specified in `app_spec.txt`.
This typically includes directories for frontend, backend, and any other
components mentioned in the spec.
### OPTIONAL: Start Implementation
If you have time remaining in this session, you may begin implementing
the highest-priority features. Get the next feature with:
```
Use the feature_get_next tool
```
Remember:
- Work on ONE feature at a time
- Test thoroughly before marking as passing
- Commit your progress before session ends
### ENDING THIS SESSION
Before your context fills up:
1. Commit all work with descriptive messages
2. Create `claude-progress.txt` with a summary of what you accomplished
3. Verify features were created using the feature_get_stats tool
4. Leave the environment in a clean, working state
The next agent will continue from here with a fresh context window.
---
**Remember:** You have unlimited time across many sessions. Focus on
quality over speed. Production-ready is the goal.