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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PricingCard, type PricingCardProps } from './PricingCard';
|
||||
export { ExpandableInfo, type ExpandableInfoProps } from './ExpandableInfo';
|
||||
export { TierComparison, type TierComparisonProps } from './TierComparison';
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { WizardContainer } from './WizardContainer';
|
||||
export { WizardProgress, type WizardProgressProps } from './WizardProgress';
|
||||
export { WizardNavigation, type WizardNavigationProps } from './WizardNavigation';
|
||||
export * from './steps';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user