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,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';
}