sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00

Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 19:59:08 -07:00
parent a1a19f8c00
commit fa15b03180
169 changed files with 879909 additions and 1243 deletions

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Fix email_service.py f-string error and quotes.py field name mismatch."""
# Fix 1: email_service.py - backslash in f-string
with open('/opt/claudetools/api/services/email_service.py', 'r') as f:
lines = f.read().split('\n')
# Find and replace the problematic line
fixed_email = False
insert_idx = None
for i, line in enumerate(lines):
if 'One-Time Costs' in line and 'fff7ed' in line:
lines[i] = ' {setup_costs_html}'
fixed_email = True
print(f'Replaced problematic f-string at line {i+1}')
break
# Find the 'return f"""' line (after line 100) and insert variable before it
for i, line in enumerate(lines):
if 'return f"""' in line and i > 100:
insert_idx = i
break
if insert_idx is not None:
var_lines = [
' setup_costs_html = ""',
' if float(setup_total or 0) > 0:',
' setup_costs_html = (',
' "<div style=\'background: #fff7ed; border-radius: 8px; padding: 12px 20px; "',
' "margin-bottom: 20px;\'><span style=\'color: #9a3412; font-size: 14px;\'>"',
' "One-Time Costs: <strong>$" + setup_total + "</strong></span></div>"',
' )',
'',
]
for j, vl in enumerate(var_lines):
lines.insert(insert_idx + j, vl)
print(f'Inserted setup_costs_html variable before line {insert_idx+1}')
with open('/opt/claudetools/api/services/email_service.py', 'w') as f:
f.write('\n'.join(lines))
print('email_service.py saved')
# Fix 2: quotes.py - item.service_name -> item.product_name
with open('/opt/claudetools/api/routers/quotes.py', 'r') as f:
content = f.read()
if 'item.service_name' in content:
content = content.replace('item.service_name', 'item.product_name')
print('Fixed item.service_name -> item.product_name in quotes.py')
else:
print('item.service_name not found in quotes.py (may already be fixed)')
with open('/opt/claudetools/api/routers/quotes.py', 'w') as f:
f.write(content)
print('quotes.py saved')
# Verify no syntax errors
import py_compile
try:
py_compile.compile('/opt/claudetools/api/services/email_service.py', doraise=True)
print('[OK] email_service.py: syntax OK')
except py_compile.PyCompileError as e:
print(f'[ERROR] email_service.py SYNTAX ERROR: {e}')
try:
py_compile.compile('/opt/claudetools/api/routers/quotes.py', doraise=True)
print('[OK] quotes.py: syntax OK')
except py_compile.PyCompileError as e:
print(f'[ERROR] quotes.py SYNTAX ERROR: {e}')

View File

@@ -0,0 +1 @@
VITE_API_URL=/msp-api

View File

@@ -2,10 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23fe7400'/><text x='50' y='68' text-anchor='middle' font-size='52' font-weight='bold' fill='white' font-family='sans-serif'>AZ</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MSP Quote Wizard - Get a custom IT services quote for your business" />
<title>MSP Quote Wizard | AZ Computer Guru</title>
<meta name="description" content="Get a custom IT services quote for your business from AZ Computer Guru - Arizona's trusted managed service provider." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet" />
<title>Get Your IT Services Quote | AZ Computer Guru</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,22 +1,66 @@
import { WizardContainer } from '@/components/wizard/WizardContainer'
import { Shield, Phone, MapPin } from 'lucide-react'
function App() {
return (
<div className="min-h-screen bg-white">
<header className="bg-[#333d49] text-white py-4 px-6">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<h1 className="text-xl font-semibold">MSP Quote Wizard</h1>
<span className="text-sm text-gray-300">Powered by AZ Computer Guru</span>
<div className="min-h-screen bg-[#f8f9fb] flex flex-col">
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-accent">
<span className="text-white font-extrabold text-sm tracking-tight" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ
</span>
</div>
<div>
<h1 className="text-lg font-bold text-[#333d49] leading-tight" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ Computer Guru
</h1>
<p className="text-xs text-gray-400 leading-tight">IT Services Quote Builder</p>
</div>
</div>
<div className="hidden sm:flex items-center gap-5 text-xs text-gray-500">
<a href="tel:15203048300" className="flex items-center gap-1.5 hover:text-[#fe7400] transition-colors">
<Phone className="w-3.5 h-3.5 text-[#fe7400]" />
(520) 304-8300
</a>
<span className="flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-[#fe7400]" />
Serving Arizona
</span>
</div>
</div>
</header>
<main className="py-8">
{/* Main content */}
<main className="flex-1 py-8 sm:py-10">
<WizardContainer />
</main>
<footer className="bg-[#113559] text-white py-6 px-6 mt-auto">
<div className="max-w-6xl mx-auto text-center text-sm">
<p>&copy; {new Date().getFullYear()} AZ Computer Guru. All rights reserved.</p>
{/* Footer */}
<footer className="bg-gradient-navy text-white py-8 px-4 sm:px-6">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-white/10">
<span className="text-white font-bold text-xs" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
AZ
</span>
</div>
<div>
<p className="text-sm font-medium text-white/90">AZ Computer Guru</p>
<p className="text-xs text-white/50">Managed IT Services for Arizona Businesses</p>
</div>
</div>
<div className="flex items-center gap-6 text-xs text-white/50">
<span className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5" />
Your data is encrypted & secure
</span>
<span>&copy; {new Date().getFullYear()} AZ Computer Guru</span>
</div>
</div>
</div>
</footer>
</div>

View File

@@ -21,22 +21,29 @@ export function ExpandableInfo({
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
<div className={cn('border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card', 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"
className="w-full flex items-center justify-between p-4 text-left hover:bg-[#f8f9fb] 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>
{icon || (
<div className="w-8 h-8 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<HelpCircle className="w-4 h-4 text-[#fe7400]" />
</div>
)}
<span className="font-semibold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5 text-gray-400" />
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</button>
@@ -48,8 +55,8 @@ export function ExpandableInfo({
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 className="px-4 pb-4 pt-0 text-sm text-gray-500 border-t border-gray-100">
<div className="pt-4 leading-relaxed">{children}</div>
</div>
</motion.div>
)}

View File

@@ -16,40 +16,43 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
return (
<motion.div
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
transition={{ duration: 0.2 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={cn(
'relative overflow-hidden',
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
tier.recommended && !isSelected && 'ring-2 ring-[#fe7400]/30'
)}
>
{/* 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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</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>
<h3 className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-sm text-gray-400 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]">
<span className="text-3xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyEstimate)}
</span>
<span className="text-gray-500">/month</span>
<span className="text-gray-400">/month</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
@@ -57,10 +60,12 @@ export function PricingCard({ tier, isSelected, deviceCount, onSelect }: Pricing
</div>
{/* Features */}
<ul className="space-y-2 mb-6">
<ul className="space-y-2.5 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" />
<li key={index} className="flex items-start gap-2.5 text-sm">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</li>
))}

View File

@@ -35,43 +35,57 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
const renderCell = (value: boolean | string) => {
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
<div className="w-5 h-5 rounded-full bg-[#ecfdf5] flex items-center justify-center mx-auto">
<Check className="w-3 h-3 text-[#059669]" strokeWidth={3} />
</div>
) : (
<X className="w-5 h-5 text-gray-300 mx-auto" />
<X className="w-4 h-4 text-gray-200 mx-auto" />
);
}
return <span className="text-sm text-[#333d49]">{value}</span>;
return (
<span className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
);
};
return (
<div className="overflow-x-auto">
<div className="overflow-x-auto rounded-xl border border-gray-200/80 shadow-card">
<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 className="text-left p-4 border-b border-gray-100 bg-[#f8f9fb]">
<span className="font-bold text-[#333d49] text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
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',
'p-4 border-b border-gray-100 text-center cursor-pointer transition-all duration-200',
selectedTier === tier.id
? 'bg-[#fe7400]/10'
: 'bg-gray-50 hover:bg-gray-100'
? 'bg-[#fe7400]/5'
: 'bg-[#f8f9fb] hover:bg-gray-100'
)}
onClick={() => onSelectTier(tier.id)}
>
<span
className={cn(
'font-semibold',
'font-bold text-sm',
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{tier.name}
</span>
{tier.recommended && (
<span className="block text-xs text-[#fe7400] mt-1">Recommended</span>
<span className="block text-[10px] text-[#fe7400] mt-0.5 font-bold uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</span>
)}
</th>
))}
@@ -79,30 +93,30 @@ export function TierComparison({ tiers, selectedTier, onSelectTier }: TierCompar
</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">
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-[#f8f9fb]/50'}>
<td className="p-4 border-b border-gray-50 text-sm text-gray-500">
{feature.name}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'essential' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.essential)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'professional' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.professional)}
</td>
<td
className={cn(
'p-4 border-b border-gray-100 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
'p-4 border-b border-gray-50 text-center',
selectedTier === 'enterprise' && 'bg-[#fe7400]/3'
)}
>
{renderCell(feature.enterprise)}

View File

@@ -22,32 +22,33 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
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';
'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-40 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
'bg-gradient-accent text-white hover:brightness-110 focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md active:brightness-95',
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]',
'border-2 border-gray-200 text-[#333d49] hover:border-[#333d49] hover:bg-gray-50 focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
'text-[#333d49] hover:bg-gray-100/80 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',
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-2.5 text-sm',
lg: 'px-8 py-3.5 text-base',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
whileHover={{ scale: disabled || isLoading ? 1 : 1.015 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.985 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
>
{isLoading ? (
@@ -72,7 +73,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
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...
Processing...
</>
) : (
children

View File

@@ -23,13 +23,20 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
},
ref
) => {
const baseStyles = 'rounded-xl transition-all duration-200';
const baseStyles = 'rounded-2xl transition-all duration-300';
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',
default: 'bg-white border border-gray-200/80',
elevated: 'bg-white border border-gray-200/60',
outlined: 'bg-transparent border-2 border-[#333d49]/20',
highlighted: 'bg-white border-2 border-[#fe7400] ring-1 ring-[#fe7400]/10',
};
const shadowStyles: Record<string, React.CSSProperties> = {
default: { boxShadow: '0 1px 2px rgba(17,53,89,0.04), 0 4px 12px rgba(17,53,89,0.06)' },
elevated: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
outlined: {},
highlighted: { boxShadow: '0 4px 6px rgba(17,53,89,0.04), 0 12px 32px rgba(17,53,89,0.08)' },
};
const paddings = {
@@ -40,15 +47,15 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
};
const hoverStyles = hoverable
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
? 'cursor-pointer hover:-translate-y-0.5'
: '';
if (hoverable) {
return (
<motion.div
ref={ref}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
whileHover={{ scale: 1.01, boxShadow: '0 2px 4px rgba(17,53,89,0.06), 0 8px 24px rgba(17,53,89,0.1)' }}
whileTap={{ scale: 0.995 }}
className={cn(
baseStyles,
variants[variant],
@@ -56,6 +63,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
hoverStyles,
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
@@ -72,6 +80,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
paddings[padding],
className
)}
style={shadowStyles[variant]}
onClick={onClick}
>
{children}
@@ -82,7 +91,6 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
Card.displayName = 'Card';
// Card subcomponents
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
@@ -99,6 +107,7 @@ const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingEleme
<h3
ref={ref}
className={cn('text-xl font-semibold text-[#333d49]', className)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
/>
)
@@ -109,7 +118,7 @@ const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLPara
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 mt-1', className)}
className={cn('text-sm text-gray-400 mt-1', className)}
{...props}
/>
)

View File

@@ -16,7 +16,8 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[#333d49] mb-1.5"
className="block text-sm font-medium text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{label}
</label>
@@ -26,13 +27,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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',
'w-full px-4 py-3 rounded-xl border transition-all duration-200',
'text-[#333d49] placeholder-gray-400 bg-white',
'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',
? 'border-red-400 focus:border-red-400 focus:ring-red-100'
: 'border-gray-200 hover:border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/15',
'disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed',
className
)}
aria-invalid={error ? 'true' : 'false'}
@@ -42,12 +43,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-500">
<p id={`${inputId}-error`} className="mt-2 text-sm text-red-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-gray-500">
<p id={`${inputId}-helper`} className="mt-2 text-sm text-gray-400">
{helperText}
</p>
)}

View File

@@ -19,26 +19,26 @@ export function ProgressBar({
const clampedProgress = Math.min(100, Math.max(0, progress));
const sizes = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
sm: 'h-1',
md: 'h-1.5',
lg: 'h-2.5',
};
const variants = {
default: 'bg-[#333d49]',
accent: 'bg-[#fe7400]',
accent: 'bg-gradient-accent',
};
return (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-1.5">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-[#333d49]">Progress</span>
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
<span className="text-sm font-semibold text-[#fe7400]">{clampedProgress}%</span>
</div>
)}
<div
className={cn('w-full bg-gray-200 rounded-full overflow-hidden', sizes[size])}
className={cn('w-full bg-gray-100 rounded-full overflow-hidden', sizes[size])}
role="progressbar"
aria-valuenow={clampedProgress}
aria-valuemin={0}
@@ -48,7 +48,7 @@ export function ProgressBar({
className={cn('h-full rounded-full', variants[variant])}
initial={{ width: 0 }}
animate={{ width: `${clampedProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
</div>

View File

@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useState, useEffect, useMemo } 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 type { WizardStepDef } from '@/hooks/useWizard';
import { useQuote } from '@/hooks/useQuote';
import {
Step1CompanyProfile,
StepWelcome,
StepServiceDiscovery,
Step2GPSMonitoring,
Step3SupportPlan,
Step4VoIP,
@@ -15,73 +17,383 @@ import {
Step7Contact,
} from './steps';
import {
Building2,
Sparkles,
LayoutGrid,
Monitor,
Headphones,
Phone,
Globe,
FileCheck,
Send,
TrendingUp,
Hash,
CircleCheck,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
import { createQuote, updateQuote, submitQuote } from '@/lib/api';
import type { QuoteSubmitRequest, QuoteItemCreateRequest } from '@/lib/api';
import {
gpsTiers,
equipmentMonitoring,
supportPlans,
blockTimeOptions,
voipTiers,
voipHardware,
webHostingTiers,
emailTiers,
} from '@/lib/pricing-data';
import type { ServiceInterests } from '@/types/quote';
/**
* 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
* Dynamic flow:
* 1. Welcome & Intake
* 2. Service Discovery (toggle interests)
* 3-N. Dynamic service configuration steps (based on selections)
* N+1. Review Quote
* N+2. Contact & Submit
*/
const stepIcons = [Building2, Monitor, Headphones, Phone, Globe, FileCheck, Send];
/** Map step IDs to icons */
const stepIconMap: Record<string, typeof Monitor> = {
welcome: Sparkles,
discovery: LayoutGrid,
gps: Monitor,
support: Headphones,
voip: Phone,
'web-email': Globe,
review: FileCheck,
submit: Send,
};
/** Fixed step definitions that always appear */
const FIXED_BEFORE: WizardStepDef[] = [
{ id: 'welcome', title: 'Welcome', description: 'Tell us about yourself' },
{ id: 'discovery', title: 'Services', description: 'Choose what interests you' },
];
const FIXED_AFTER: WizardStepDef[] = [
{ id: 'review', title: 'Review', description: 'Review your selections' },
{ id: 'submit', title: 'Submit', description: 'Get your quote' },
];
/** Service step definitions — included only when toggled on */
const SERVICE_STEPS: { key: keyof ServiceInterests; step: WizardStepDef }[] = [
{ key: 'gps', step: { id: 'gps', title: 'Monitoring', description: 'Configure your monitoring tier' } },
{ key: 'support', step: { id: 'support', title: 'Support', description: 'Choose your support level' } },
{ key: 'voip', step: { id: 'voip', title: 'VoIP', description: 'Business phone options' } },
{ key: 'webHosting', step: { id: 'web-email', title: 'Web & Email', description: 'Hosting and email services' } },
];
function buildDynamicSteps(interests: ServiceInterests): WizardStepDef[] {
const dynamicMiddle: WizardStepDef[] = [];
for (const { key, step } of SERVICE_STEPS) {
// Special case: web-email step shows if either webHosting or email is selected
if (key === 'webHosting') {
if (interests.webHosting || interests.email) {
dynamicMiddle.push(step);
}
} else if (interests[key]) {
dynamicMiddle.push(step);
}
}
return [...FIXED_BEFORE, ...dynamicMiddle, ...FIXED_AFTER];
}
export function WizardContainer() {
const wizard = useWizard();
const quote = useQuote();
// Build dynamic step list based on service interests
const stepDefs = useMemo(
() => buildDynamicSteps(quote.quoteData.serviceInterests),
[quote.quoteData.serviceInterests]
);
const wizard = useWizard(stepDefs);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(() => {
try {
const stored = localStorage.getItem('quote-wizard-draft');
if (stored) {
const parsed = JSON.parse(stored);
return parsed.accessToken || null;
}
} catch {
// Ignore parse errors
}
return null;
});
const StepIcon = stepIcons[wizard.currentStep] || Building2;
const currentStepId = wizard.currentStepId;
const StepIcon = stepIconMap[currentStepId] || Sparkles;
const currentStepData = wizard.steps[wizard.currentStep];
// Create a draft quote when leaving the discovery step
useEffect(() => {
if (currentStepId !== 'welcome' && currentStepId !== 'discovery' && !accessToken) {
createDraftQuote();
}
}, [currentStepId]); // eslint-disable-line react-hooks/exhaustive-deps
async function createDraftQuote(): Promise<string | null> {
try {
const response = await createQuote({
employee_count: quote.quoteData.company.endpointCount || undefined,
notes: quote.quoteData.company.notes || undefined,
});
setAccessToken(response.access_token);
try {
const existing = localStorage.getItem('quote-wizard-draft');
const draft = existing ? JSON.parse(existing) : {};
draft.accessToken = response.access_token;
localStorage.setItem('quote-wizard-draft', JSON.stringify(draft));
} catch {
// localStorage write failures are non-critical
}
return response.access_token;
} catch (error) {
console.error('Failed to create quote draft:', error);
return null;
}
}
/** Build quote line items from wizard selections */
function buildQuoteItems(): QuoteItemCreateRequest[] {
const items: QuoteItemCreateRequest[] = [];
const data = quote.quoteData;
const interests = data.serviceInterests;
// GPS Monitoring (if interested)
if (interests.gps) {
const gpsTier = gpsTiers.find((t) => t.id === data.gps.tierId);
if (gpsTier) {
items.push({
product_code: `gps-${gpsTier.id}`,
product_name: `GPS ${gpsTier.name} Monitoring`,
description: gpsTier.description,
category: 'gps_monitoring',
billing_frequency: 'monthly',
unit_price: gpsTier.pricePerEndpoint.toFixed(2),
quantity: data.gps.endpointCount,
tier: gpsTier.id,
});
}
if (data.gps.includeEquipment && data.gps.equipmentDeviceCount > 0) {
const additionalDevices = Math.max(0, data.gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
const eqTotal = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
items.push({
product_code: 'equip-pack',
product_name: 'Equipment Pack Monitoring',
description: `${data.gps.equipmentDeviceCount} devices`,
category: 'gps_monitoring',
billing_frequency: 'monthly',
unit_price: eqTotal.toFixed(2),
quantity: 1,
});
}
}
// Support plan (if interested)
if (interests.support && data.support.planId !== 'none') {
const plan = supportPlans.find((p) => p.id === data.support.planId);
if (plan) {
items.push({
product_code: `support-${plan.id}`,
product_name: `${plan.name} Support Plan`,
description: `${plan.includedHours} hours/month included`,
category: 'support_plan',
billing_frequency: 'monthly',
unit_price: plan.monthlyPrice.toFixed(2),
quantity: 1,
tier: plan.id,
});
}
}
// Block time (one-time)
if (interests.support && data.support.useBlockTime && data.support.blockTimeId) {
const block = blockTimeOptions.find((b) => b.id === data.support.blockTimeId);
if (block) {
items.push({
product_code: `block-${block.id}`,
product_name: `Block Time (${block.hours} hours)`,
description: `Pre-purchased support hours at ${formatCurrency(block.effectiveHourlyRate)}/hr`,
category: 'support_plan',
billing_frequency: 'one_time',
unit_price: block.price.toFixed(2),
quantity: 1,
});
}
}
// VoIP (if interested)
if (interests.voip && data.voip.enabled) {
const vTier = voipTiers.find((t) => t.id === data.voip.tierId);
if (vTier && data.voip.userCount > 0) {
items.push({
product_code: `voip-${vTier.id}`,
product_name: `VoIP ${vTier.name} Plan`,
description: vTier.description,
category: 'voip',
billing_frequency: 'monthly',
unit_price: vTier.pricePerUser.toFixed(2),
quantity: data.voip.userCount,
tier: vTier.id,
});
}
data.voip.hardware.forEach((hw) => {
const hwDef = voipHardware.find((h) => h.id === hw.hardwareId);
if (hwDef && hw.quantity > 0) {
if (hw.isRental) {
items.push({
product_code: `voip-hw-${hwDef.id}-rental`,
product_name: `${hwDef.name} (Rental)`,
description: hwDef.description,
category: 'voip',
billing_frequency: 'monthly',
unit_price: hwDef.monthlyRental.toFixed(2),
quantity: hw.quantity,
});
} else {
items.push({
product_code: `voip-hw-${hwDef.id}-purchase`,
product_name: `${hwDef.name} (Purchase)`,
description: hwDef.description,
category: 'voip',
billing_frequency: 'one_time',
unit_price: hwDef.oneTimePrice.toFixed(2),
quantity: hw.quantity,
});
}
}
});
}
// Web hosting (if interested)
if (interests.webHosting && data.webHosting.enabled) {
const wTier = webHostingTiers.find((t) => t.id === data.webHosting.tierId);
if (wTier) {
items.push({
product_code: `web-${wTier.id}`,
product_name: `${wTier.name} Web Hosting`,
description: `${wTier.storage}, ${wTier.sites === -1 ? 'unlimited' : wTier.sites} sites`,
category: 'web_hosting',
billing_frequency: 'monthly',
unit_price: wTier.monthlyPrice.toFixed(2),
quantity: 1,
tier: wTier.id,
});
}
}
// Email (if interested)
if (interests.email && data.email.enabled && data.email.mailboxCount > 0) {
const eTier = emailTiers.find((t) => t.id === data.email.tierId);
if (eTier) {
items.push({
product_code: `email-${eTier.id}`,
product_name: eTier.name,
description: `${eTier.storage} storage per mailbox`,
category: 'email',
billing_frequency: 'monthly',
unit_price: eTier.pricePerMailbox.toFixed(2),
quantity: data.email.mailboxCount,
tier: eTier.id,
});
}
}
return items;
}
const handleNext = () => {
// Calculate quote before moving to summary
if (wizard.currentStep === 4) {
setSubmitError(null);
// Calculate quote before entering review
if (wizard.steps[wizard.currentStep + 1]?.id === 'review') {
quote.calculateQuote();
}
wizard.nextStep();
};
const handlePrev = () => {
setSubmitError(null);
wizard.prevStep();
};
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
// Calculate final quote
const result = quote.calculateQuote();
quote.calculateQuote();
try {
// Simulate API submission
await new Promise((resolve) => setTimeout(resolve, 2000));
let token = accessToken;
const items = buildQuoteItems();
// Log submission (in production, this would send to an API)
console.log('Quote submitted:', {
quoteData: quote.quoteData,
quoteResult: result,
timestamp: new Date().toISOString(),
});
if (!token) {
const response = await createQuote({
employee_count: quote.quoteData.company.endpointCount || undefined,
notes: quote.quoteData.company.notes || undefined,
items,
});
token = response.access_token;
setAccessToken(token);
} else {
const companyData = quote.quoteData.company;
await updateQuote(token, {
company_name: companyData.name || undefined,
employee_count: companyData.endpointCount || undefined,
notes: companyData.notes || undefined,
items,
});
}
const contactData = quote.quoteData.contact;
const companyData = quote.quoteData.company;
const submitData: QuoteSubmitRequest = {
company_name: contactData.companyName || companyData.name || contactData.name,
contact_name: contactData.name,
contact_email: contactData.email,
contact_phone: contactData.phone || undefined,
notes: contactData.currentITSituation || companyData.notes || undefined,
};
await submitQuote(token, submitData);
localStorage.removeItem('quote-wizard-draft');
setSubmitSuccess(true);
} catch (error) {
} catch (error: unknown) {
console.error('Submission error:', error);
// Handle error state here
let message = 'An unexpected error occurred. Please try again.';
if (error instanceof Error) {
message = error.message;
}
if (
typeof error === 'object' &&
error !== null &&
'response' in error
) {
const axiosError = error as { response?: { data?: { detail?: string }; status?: number } };
if (axiosError.response?.data?.detail) {
message = axiosError.response.data.detail;
} else if (axiosError.response?.status === 400) {
message = 'Quote cannot be submitted. Please review your selections and try again.';
} else if (axiosError.response?.status === 404) {
message = 'Quote session expired. Please start a new quote.';
}
}
setSubmitError(message);
} finally {
setIsSubmitting(false);
}
@@ -91,35 +403,44 @@ export function WizardContainer() {
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
switch (currentStepId) {
case 'welcome':
return (
!quote.quoteData.contact.name.trim() ||
!quote.quoteData.contact.email.trim() ||
!quote.quoteData.contact.agreedToTerms
quote.quoteData.company.endpointCount < 1
);
case 'submit':
return !quote.quoteData.contact.agreedToTerms;
default:
return false;
}
};
// Render current step content
const renderStepContent = () => {
switch (wizard.currentStep) {
case 0:
switch (currentStepId) {
case 'welcome':
return (
<Step1CompanyProfile
<StepWelcome
clientType={quote.quoteData.clientType}
companyInfo={quote.quoteData.company}
contactInfo={quote.quoteData.contact}
onSetClientType={quote.setClientType}
onUpdateCompany={quote.updateCompany}
onUpdateContact={quote.updateContact}
onSetEndpointCount={quote.setEndpointCount}
onSetIndustry={quote.setIndustry}
/>
);
case 1:
case 'discovery':
return (
<StepServiceDiscovery
serviceInterests={quote.quoteData.serviceInterests}
onSetServiceInterest={quote.setServiceInterest}
/>
);
case 'gps':
return (
<Step2GPSMonitoring
gpsSelection={quote.quoteData.gps}
@@ -129,7 +450,7 @@ export function WizardContainer() {
getGPSMonthly={quote.getGPSMonthly}
/>
);
case 2:
case 'support':
return (
<Step3SupportPlan
supportSelection={quote.quoteData.support}
@@ -138,9 +459,10 @@ export function WizardContainer() {
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
onSetBlockTime={quote.setBlockTime}
getSupportMonthly={quote.getSupportMonthly}
getSupportBlockTimeOneTime={quote.getSupportBlockTimeOneTime}
/>
);
case 3:
case 'voip':
return (
<Step4VoIP
voipSelection={quote.quoteData.voip}
@@ -154,7 +476,7 @@ export function WizardContainer() {
getVoIPOneTime={quote.getVoIPOneTime}
/>
);
case 4:
case 'web-email':
return (
<Step5WebEmail
webHostingSelection={quote.quoteData.webHosting}
@@ -169,7 +491,7 @@ export function WizardContainer() {
getEmailMonthly={quote.getEmailMonthly}
/>
);
case 5:
case 'review':
return (
<Step6Summary
quoteData={quote.quoteData}
@@ -178,7 +500,7 @@ export function WizardContainer() {
onCalculateQuote={quote.calculateQuote}
/>
);
case 6:
case 'submit':
return (
<Step7Contact
contactInfo={quote.quoteData.contact}
@@ -196,55 +518,72 @@ export function WizardContainer() {
}
};
// Running total calculation (only include interested services)
const interests = quote.quoteData.serviceInterests;
const runningMonthly =
(interests.gps ? quote.getGPSMonthly() : 0) +
(interests.support ? quote.getSupportMonthly() : 0) +
(interests.voip ? quote.getVoIPMonthly() : 0) +
(interests.webHosting ? quote.getWebHostingMonthly() : 0) +
(interests.email ? quote.getEmailMonthly() : 0);
// Success state
if (submitSuccess) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<Card variant="elevated" padding="lg">
<CardContent>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-12"
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
className="text-center py-12 sm:py-16"
>
<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!
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.2 }}
className="w-20 h-20 bg-[#ecfdf5] rounded-full flex items-center justify-center mx-auto mb-8"
>
<CircleCheck className="w-10 h-10 text-[#059669]" />
</motion.div>
<h2 className="text-3xl font-bold text-[#333d49] mb-3">
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 className="text-gray-500 mb-10 max-w-md mx-auto leading-relaxed">
Thank you for your interest. Our team will review your custom quote and
contact you within one business day.
</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>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-[#f8f9fb] rounded-2xl p-8 max-w-sm mx-auto mb-10"
>
<p className="text-sm text-gray-400 mb-1 uppercase tracking-wide font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontSize: '11px', letterSpacing: '0.08em' }}>
Your Estimated Monthly Investment
</p>
</div>
<p className="text-4xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quote.quoteResult.monthlyTotal)}
<span className="text-base font-medium text-gray-400 ml-1">/mo</span>
</p>
</motion.div>
)}
<button
onClick={() => {
quote.resetQuote();
wizard.resetWizard();
setSubmitSuccess(false);
setAccessToken(null);
setSubmitError(null);
}}
className="text-[#fe7400] hover:text-[#e56800] font-medium"
className="text-[#fe7400] hover:text-[#e56800] font-semibold transition-colors"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Start a New Quote
</button>
@@ -256,9 +595,9 @@ export function WizardContainer() {
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Progress indicator */}
<div className="mb-8">
<div className="mb-8 sm:mb-10 print-hide">
<WizardProgress
steps={wizard.steps}
currentStep={wizard.currentStep}
@@ -267,74 +606,99 @@ export function WizardContainer() {
</div>
{/* Main wizard card */}
<Card variant="elevated" padding="lg">
<Card variant="elevated" padding="none" className="overflow-hidden">
<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 className="px-4 sm:px-6 md:px-8 pt-5 sm:pt-6 md:pt-8 pb-5 sm:pb-6 border-b border-gray-100 bg-white print-hide">
<div className="flex items-center gap-3 sm:gap-4">
<div className="flex items-center justify-center w-9 h-9 sm:w-11 sm:h-11 rounded-xl bg-[#fe7400]/8 flex-shrink-0">
<StepIcon className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#333d49] truncate">
{currentStepData?.title}
</h2>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5 truncate">{currentStepData?.description}</p>
</div>
</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}
/>
{/* Error banner */}
{submitError && (
<div className="mx-4 sm:mx-6 md:mx-8 mt-4 sm:mt-6 p-3 sm:p-4 bg-red-50 border border-red-100 rounded-xl">
<p className="text-red-600 text-sm font-medium">{submitError}</p>
</div>
)}
{/* Step content with animation */}
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6 md:py-8">
<AnimatePresence mode="wait">
<motion.div
key={currentStepId}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -16 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="min-h-[400px]"
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
{/* Navigation — hidden on submit step (has its own submit button) */}
{currentStepId !== 'submit' && (
<WizardNavigation
onNext={handleNext}
onPrev={handlePrev}
onSubmit={handleSubmit}
isFirstStep={wizard.isFirstStep}
isLastStep={wizard.isLastStep}
isNextDisabled={isNextDisabled()}
isSubmitting={isSubmitting}
/>
)}
</div>
</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]">
{/* Running totals bar */}
<div className="mt-5 grid grid-cols-3 gap-2 sm:gap-3">
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<Hash className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-gray-400" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Endpoints
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{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()
)}
</div>
<div className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<TrendingUp className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-[#fe7400]" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Monthly
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(runningMonthly)}
</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 className="bg-white rounded-xl border border-gray-200/80 shadow-sm p-2.5 sm:p-4 text-center">
<div className="flex items-center justify-center gap-1 sm:gap-1.5 mb-1">
<CircleCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-gray-400" />
<p className="text-[10px] sm:text-[11px] font-medium text-gray-400 uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Progress
</p>
</div>
<p className="text-lg sm:text-2xl font-bold text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{wizard.progress}%
</p>
</div>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ChevronLeft, ChevronRight, Send } from 'lucide-react';
import { Button } from '@/components/ui';
export interface WizardNavigationProps {
@@ -21,26 +21,28 @@ export function WizardNavigation({
isSubmitting = false,
}: WizardNavigationProps) {
return (
<div className="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
<div className="flex items-center justify-between pt-8 mt-8 border-t border-gray-100">
<Button
type="button"
variant="outline"
variant="ghost"
onClick={onPrev}
disabled={isFirstStep}
className={isFirstStep ? 'invisible' : ''}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
<ChevronLeft className="w-4 h-4 mr-1.5" />
Back
</Button>
{isLastStep ? (
<Button
type="button"
variant="primary"
size="lg"
onClick={onSubmit}
isLoading={isSubmitting}
disabled={isNextDisabled || isSubmitting}
>
<Send className="w-4 h-4 mr-2" />
Get My Quote
</Button>
) : (
@@ -50,8 +52,8 @@ export function WizardNavigation({
onClick={onNext}
disabled={isNextDisabled}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
Continue
<ChevronRight className="w-4 h-4 ml-1.5" />
</Button>
)}
</div>

View File

@@ -14,34 +14,33 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
return (
<nav aria-label="Progress" className="w-full">
<ol className="flex items-center justify-between">
{/* Desktop stepper */}
<ol className="flex items-start justify-between">
{steps.map((step, index) => {
const isCompleted = step.isComplete;
const isCurrent = index === currentStep;
const isClickable = isCompleted || index <= currentStep;
const isLast = index === steps.length - 1;
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')
'relative flex flex-col items-center',
!isLast && 'flex-1'
)}
>
{/* Connector line */}
{index !== steps.length - 1 && (
{!isLast && (
<div
className={cn(
'absolute top-4 right-0 h-0.5 bg-gray-200',
isCompactMode ? 'left-6' : 'left-8'
)}
className="absolute top-[18px] left-[calc(50%+18px)] right-[calc(-50%+18px)] h-[2px] bg-gray-200"
aria-hidden="true"
>
<motion.div
className="h-full bg-[#fe7400]"
initial={{ width: '0%' }}
animate={{ width: isCompleted ? '100%' : '0%' }}
transition={{ duration: 0.3 }}
className="h-full bg-[#fe7400] origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: isCompleted ? 1 : 0 }}
transition={{ duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }}
/>
</div>
)}
@@ -51,7 +50,7 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'group flex flex-col items-center',
'group relative z-10 flex flex-col items-center gap-2',
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
)}
aria-current={isCurrent ? 'step' : undefined}
@@ -59,42 +58,54 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
{/* 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',
'relative flex items-center justify-center rounded-full transition-all duration-300',
isCompactMode ? 'h-7 w-7' : 'h-9 w-9',
isCompleted
? 'bg-[#fe7400] border-[#fe7400]'
? 'bg-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: isCurrent
? 'border-[#fe7400] bg-white'
: 'border-gray-300 bg-white'
? 'bg-white border-[2.5px] border-[#fe7400] shadow-[0_0_0_4px_rgba(254,116,0,0.1)]'
: 'bg-white border-2 border-gray-200'
)}
whileHover={isClickable ? { scale: 1.1 } : {}}
whileHover={isClickable ? { scale: 1.08 } : {}}
whileTap={isClickable ? { scale: 0.95 } : {}}
layout
>
{isCompleted ? (
<Check className={cn(isCompactMode ? 'h-3 w-3' : 'h-4 w-4', 'text-white')} />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Check className={cn(isCompactMode ? 'h-3.5 w-3.5' : 'h-4 w-4', 'text-white')} strokeWidth={3} />
</motion.div>
) : (
<span
className={cn(
'font-semibold',
'font-bold',
isCompactMode ? 'text-xs' : 'text-sm',
isCurrent ? 'text-[#fe7400]' : 'text-gray-500'
isCurrent ? 'text-[#fe7400]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{index + 1}
</span>
)}
</motion.div>
{/* Step label - hidden on mobile for compact mode */}
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
{/* Step label */}
<div className={cn(
'text-center max-w-[80px]',
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'
'font-medium whitespace-nowrap leading-tight block',
isCompactMode ? 'text-[10px]' : 'text-[11px]',
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-400'
)}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{isCompactMode ? step.title.split(' ')[0] : step.title}
{step.title}
</span>
</div>
</button>
@@ -106,10 +117,10 @@ export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgre
{/* 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 className="text-xs text-gray-400">
Step {currentStep + 1} of {steps.length}
</span>
<span className="text-sm font-medium text-[#333d49] ml-1">
<span className="text-sm font-semibold text-[#333d49] ml-2">
{steps[currentStep]?.title}
</span>
</div>

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { Building2, Users, Briefcase, MessageSquare } from 'lucide-react';
import { Building2, Users, Briefcase, MessageSquare, Shield, Monitor, Headphones, ArrowRight } from 'lucide-react';
import { Input } from '@/components/ui';
import { industries } from '@/lib/pricing-data';
import type { CompanyInfo, Industry } from '@/types/quote';
@@ -33,101 +33,184 @@ export function Step1CompanyProfile({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
className="space-y-8"
>
{/* 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
{/* Welcome / Intro Section */}
<div className="space-y-4">
<h3 className="text-xl sm:text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Welcome to Arizona Computer Guru
</h3>
<p className="text-sm sm:text-base text-gray-500 leading-relaxed max-w-3xl">
We're a <strong className="text-[#333d49]">Managed Service Provider (MSP)</strong> serving
businesses across Arizona. An MSP acts as your outsourced IT department &mdash; we proactively
manage, monitor, and secure your technology so you can focus on running your business.
</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"
{/* What You Get - 3 cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<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 className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Monitor className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
GPS Monitoring
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Our <strong className="text-gray-500">Guru Protection Suite</strong> provides 24/7
remote monitoring, patch management, antivirus, and help desk support for every endpoint.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Headphones className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Support Plans
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Flexible support tiers from basic help desk to fully managed IT with dedicated
engineers and guaranteed response times.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#f8f9fb] rounded-xl p-4 border border-gray-200/50"
>
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center mb-3">
<Shield className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<h4 className="font-semibold text-[#333d49] text-sm mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
VoIP, Web & Email
</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Business phone systems, web hosting, and professional email &mdash; all managed
alongside your IT for a single point of contact.
</p>
</motion.div>
</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"
/>
{/* How It Works */}
<div className="flex items-center gap-2 text-xs text-gray-400">
<ArrowRight className="w-3.5 h-3.5 text-[#fe7400]" />
<span>
This wizard builds a custom quote in about 2 minutes. Tell us about your business to get started.
</span>
</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>
{/* Divider */}
<div className="border-t border-gray-100" />
{/* Form Section */}
<div className="space-y-6">
{/* Company Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<Input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Enter your company name"
className="max-w-lg"
/>
</div>
{/* Number of Endpoints */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Users className="w-4 h-4 text-[#fe7400]" />
Number of Endpoints / Employees
<span className="text-red-500 text-xs">*</span>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<Input
type="number"
min={1}
value={companyInfo.endpointCount}
onChange={handleEndpointChange}
className="w-full sm:w-32"
/>
<span className="text-xs sm:text-sm text-gray-400">
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]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Briefcase className="w-4 h-4 text-[#fe7400]" />
Industry
</label>
<select
value={companyInfo.industry}
onChange={handleIndustryChange}
className="w-full max-w-lg px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239aa1ac' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 12px center',
backgroundRepeat: 'no-repeat',
backgroundSize: '20px 20px',
paddingRight: '40px'
}}
>
<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 */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
What brings you here today?
<span className="text-gray-300 font-normal text-xs">(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-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
</div>
</motion.div>
);
}

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import { Check, Server, HardDrive } from 'lucide-react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Server, HardDrive, ChevronDown } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
@@ -21,6 +22,12 @@ export function Step2GPSMonitoring({
onSetEquipmentCount,
getGPSMonthly,
}: Step2GPSMonitoringProps) {
const [expandedTiers, setExpandedTiers] = useState<Record<string, boolean>>({});
const toggleTierExpanded = (tierId: string) => {
setExpandedTiers(prev => ({ ...prev, [tierId]: !prev[tierId] }));
};
const calculateEquipmentPrice = () => {
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
return 0;
@@ -36,19 +43,38 @@ export function Step2GPSMonitoring({
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">GPS (Guru Protection Suite)</strong> is our core
managed monitoring service. We install a lightweight agent on each of your endpoints that
runs 24/7 in the background &mdash; watching system health, disk space, CPU/memory usage,
security status, and more.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
When an issue is detected, our team is automatically alerted and can often resolve problems
remotely before you even notice. GPS also includes automated patch management to keep
Windows and third-party apps up to date, enterprise antivirus protection, and access to
our help desk for day-to-day questions. Higher tiers add 24/7 support, advanced endpoint
protection, backup and disaster recovery, and dedicated account management.
</p>
</div>
{/* Endpoint Count Display */}
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between bg-[#f8f9fb] rounded-xl 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>
<span className="font-medium text-[#333d49]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Endpoints to Monitor
</span>
</div>
<span className="text-2xl font-bold text-[#fe7400]">
<span className="text-2xl font-bold text-[#fe7400]" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{gpsSelection.endpointCount}
</span>
</div>
{/* Tier Selection Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{gpsTiers.map((tier, index) => {
const isSelected = gpsSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
@@ -59,39 +85,42 @@ export function Step2GPSMonitoring({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</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 className="mb-4">
<h3 className="text-lg font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-sm text-gray-400 mt-0.5">{tier.description}</p>
</div>
{/* Pricing */}
<div className="mb-4">
<div className="mb-5">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]">
<span className="text-3xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/month</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
@@ -99,16 +128,45 @@ export function Step2GPSMonitoring({
</div>
{/* Features */}
<ul className="space-y-2 mb-4">
<ul className="space-y-2.5 mb-5">
{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" />
<li key={idx} className="flex items-start gap-2.5 text-sm">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</li>
))}
<AnimatePresence>
{expandedTiers[tier.id] && tier.features.slice(4).map((feature, idx) => (
<motion.li
key={`extra-${idx}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="flex items-start gap-2.5 text-sm"
>
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-600">{feature}</span>
</motion.li>
))}
</AnimatePresence>
{tier.features.length > 4 && (
<li className="text-xs text-[#fe7400]">
+{tier.features.length - 4} more features
<li>
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleTierExpanded(tier.id); }}
className="flex items-center gap-1 text-xs text-[#fe7400] font-medium pl-6.5 hover:text-[#e56800] transition-colors"
>
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${expandedTiers[tier.id] ? 'rotate-180' : ''}`} />
{expandedTiers[tier.id]
? 'Show less'
: `+${tier.features.length - 4} more features`
}
</button>
</li>
)}
</ul>
@@ -133,26 +191,31 @@ export function Step2GPSMonitoring({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="border border-gray-200 rounded-lg p-5"
className="border border-gray-200/80 rounded-xl p-5 bg-white shadow-card"
>
<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">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<HardDrive className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Equipment Pack Monitoring
</h4>
<p className="text-xs sm:text-sm text-gray-400">
Monitor routers, switches, printers, and other network equipment
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<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>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 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>
@@ -164,30 +227,34 @@ export function Step2GPSMonitoring({
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>
<label className="text-sm text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
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]"
className="w-24 px-3 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all"
/>
</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>
<div className="bg-[#f8f9fb] rounded-xl p-4">
<p className="text-sm text-gray-500">
<span className="font-semibold text-[#333d49]">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
{' '}for up to {equipmentMonitoring.baseDevices} devices
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
<span>
{' + '}
<span className="font-medium">
<span className="font-semibold text-[#333d49]">
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
</span>
{' for additional devices'}
</span>
)}
</p>
<p className="text-sm font-medium text-[#fe7400] mt-1">
<p className="text-sm font-bold text-[#fe7400] mt-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Equipment total: {formatCurrency(calculateEquipmentPrice())}/month
</p>
</div>
@@ -197,32 +264,40 @@ export function Step2GPSMonitoring({
{/* 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" />
<ul className="space-y-3">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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">
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">GPS Monitoring Monthly Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getGPSMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
</motion.div>

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { Check, Clock, DollarSign, Zap } from 'lucide-react';
import { Check, Clock, DollarSign, Zap, Ban } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
@@ -13,6 +13,7 @@ export interface Step3SupportPlanProps {
onSetBlockTimeEnabled: (enabled: boolean) => void;
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
getSupportMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
}
export function Step3SupportPlan({
@@ -22,8 +23,8 @@ export function Step3SupportPlan({
onSetBlockTimeEnabled,
onSetBlockTime,
getSupportMonthly,
getSupportBlockTimeOneTime,
}: Step3SupportPlanProps) {
// Recommend plan based on endpoint count
const getRecommendedPlan = (): SupportPlanId => {
if (endpointCount <= 10) return 'essential';
if (endpointCount <= 25) return 'standard';
@@ -32,6 +33,7 @@ export function Step3SupportPlan({
};
const recommendedPlanId = getRecommendedPlan();
const isNoPlan = supportSelection.planId === 'none';
return (
<motion.div
@@ -40,8 +42,86 @@ export function Step3SupportPlan({
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">
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">Support plans</strong> give your team direct access
to our IT engineers for troubleshooting, questions, and project work. Each plan includes
a set number of monthly support hours covering help desk calls, remote assistance,
and on-site visits (Premium and Priority tiers).
</p>
<p className="text-sm text-gray-400 leading-relaxed">
Hours are used as-needed throughout the month &mdash; whether it's a quick password reset, a
printer issue, or a more involved project like setting up a new workstation.
If you don't need a monthly plan, you can skip it entirely and use block time
for occasional projects, or simply pay as you go at our standard hourly rate.
</p>
</div>
{/* Plan Selection Cards - No Plan + 4 plans = 5 columns on large screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
{/* No Plan / Pay-as-you-go Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
whileHover={{ y: -3 }}
>
<Card
variant={isNoPlan ? 'highlighted' : 'default'}
padding="none"
className="relative overflow-hidden cursor-pointer h-full"
onClick={() => onSetSupportPlan('none')}
>
<div className="p-4">
{/* Header */}
<h3 className="text-base font-bold text-[#333d49] mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
No Plan
</h3>
<p className="text-xs text-gray-400 mb-3">Pay-as-you-go or block time only</p>
{/* Pricing */}
<div className="mb-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
$0
</span>
<span className="text-gray-400 text-xs">/mo</span>
</div>
</div>
{/* No included hours */}
<div className="flex items-center gap-2 mb-3 p-2.5 bg-[#f8f9fb] rounded-lg">
<Ban className="w-4 h-4 text-gray-400" />
<span className="text-sm font-semibold text-gray-400"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
No monthly hours
</span>
</div>
{/* Standard rate note */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-400">
<DollarSign className="w-4 h-4" />
<span>
$175/hr standard rate
</span>
</div>
{/* Select Button */}
<Button
variant={isNoPlan ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isNoPlan ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
{/* Monthly Plan Cards */}
{supportPlans.map((plan, index) => {
const isSelected = supportSelection.planId === plan.id;
const isRecommended = plan.id === recommendedPlanId;
@@ -51,51 +131,55 @@ export function Step3SupportPlan({
key={plan.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
transition={{ delay: (index + 1) * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : isRecommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
isRecommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
isRecommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended for You
</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>
<h3 className="text-base font-bold text-[#333d49] mb-1"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{plan.name}
</h3>
<p className="text-xs text-gray-400 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]">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(plan.monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<span className="text-gray-400 text-xs">/mo</span>
</div>
</div>
{/* Hours Included */}
<div className="flex items-center gap-2 mb-3 p-2 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3 p-2.5 bg-[#f8f9fb] rounded-lg">
<Clock className="w-4 h-4 text-[#fe7400]" />
<span className="text-sm font-medium text-[#333d49]">
<span className="text-sm font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{plan.includedHours} hrs included
</span>
</div>
{/* Effective Rate */}
<div className="flex items-center gap-2 mb-4 text-sm text-gray-600">
<div className="flex items-center gap-2 mb-4 text-sm text-gray-400">
<DollarSign className="w-4 h-4" />
<span>
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
@@ -117,31 +201,55 @@ export function Step3SupportPlan({
})}
</div>
{/* Pay-as-you-go info when No Plan is selected */}
{isNoPlan && !supportSelection.useBlockTime && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className="bg-[#f8f9fb] rounded-xl p-4 text-sm text-gray-500"
>
<p>
Without a support plan, any support work will be billed at our standard hourly rate of
<strong className="text-[#333d49]"> $175/hr</strong>. You can add block time below
to pre-purchase hours at a discounted rate, or proceed without any support commitment.
</p>
</motion.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"
className="border border-gray-200/80 rounded-xl p-5 bg-white shadow-card"
>
<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
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Zap className="w-4.5 h-4.5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{isNoPlan ? 'Add Block Time' : 'Add Extra Block Time'}
</h4>
<p className="text-xs sm:text-sm text-gray-400">
{isNoPlan
? 'Pre-purchase support hours at a discounted rate instead of pay-as-you-go'
: 'Pre-purchase additional support hours at a discounted rate'
}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<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>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 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>
@@ -152,30 +260,33 @@ export function Step3SupportPlan({
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">
<div className="grid grid-cols-1 sm: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 ${
className={`p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-lg font-bold text-[#333d49]">
<div className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{option.hours} Hours
</div>
<div className="text-xl font-bold text-[#fe7400]">
<div className="text-xl font-bold text-[#fe7400]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(option.price)}
</div>
<div className="text-sm text-gray-500">
<div className="text-sm text-gray-400">
{formatCurrency(option.effectiveHourlyRate)}/hr
</div>
{option.hours === 30 && (
<div className="mt-2 text-xs font-medium text-green-600">
<div className="mt-2 text-[11px] font-bold text-[#059669] bg-[#ecfdf5] px-2 py-1 rounded-md inline-block uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Best Value
</div>
)}
@@ -191,44 +302,75 @@ export function Step3SupportPlan({
<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,
Monthly support plans include a set number of hours for help desk assistance,
remote troubleshooting, and project work. Hours roll over for 30 days if unused.
If you prefer not to commit to a monthly plan, you can use block time for planned
projects or pay our standard hourly rate as needed.
</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" />
<ul className="space-y-2.5">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Block Time:</strong> Pre-purchase hours at a discount for planned projects</span>
</li>
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span><strong>Pay-as-you-go:</strong> No commitment &mdash; billed at $175/hr standard rate</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 className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="text-sm sm:text-base font-medium opacity-90">Support Monthly Cost</span>
{isNoPlan && (
<p className="text-xs sm:text-sm opacity-50">
Pay-as-you-go at $175/hr
</p>
)}
</div>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getSupportMonthly())}
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
<span className="text-3xl font-bold">
{formatCurrency(getSupportMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
</span>
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
<div className="flex items-center justify-between gap-3 mt-3 pt-3 border-t border-white/15">
<div className="min-w-0">
<span className="text-sm sm:text-base font-medium opacity-90">Block Time</span>
<p className="text-xs sm:text-sm opacity-50">
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hours one-time purchase
</p>
</div>
<span className="text-xl sm:text-2xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getSupportBlockTimeOneTime())}
</span>
</div>
)}
</div>
</motion.div>
);

View File

@@ -60,27 +60,50 @@ export function Step4VoIP({
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">VoIP (Voice over IP)</strong> replaces traditional
phone lines with a modern cloud-based phone system. Your calls travel over the internet,
which means lower costs, more features, and the flexibility to take calls from your
desk phone, computer, or mobile app &mdash; anywhere with an internet connection.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
Every plan includes unlimited local and long-distance calling, auto-attendant (press 1
for sales, etc.), voicemail-to-email, call forwarding, and the ability to keep your
existing phone numbers. Higher tiers add call recording, analytics, CRM integrations,
and video conferencing. We can also provide desk phones and headsets as a purchase or
monthly rental.
</p>
</div>
{/* 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">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Phone className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Do you need business phones?
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Modern VoIP phone system with advanced features
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<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">
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 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-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{voipSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
@@ -97,19 +120,22 @@ export function Step4VoIP({
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>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-4 border border-gray-200/80 rounded-xl bg-white shadow-card">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
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"
className="w-full sm:w-24"
/>
</div>
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{voipTiers.map((tier, index) => {
const isSelected = voipSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
@@ -120,43 +146,48 @@ export function Step4VoIP({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</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>
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<span className="text-gray-400 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerUser)}/user
</p>
</div>
<ul className="space-y-1 mb-4">
<ul className="space-y-1.5 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 key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
@@ -176,19 +207,21 @@ export function Step4VoIP({
</div>
{/* Hardware Section */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card">
<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"
className="w-full flex items-center justify-between p-4 bg-[#f8f9fb] 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]">
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-500">
<span className="text-sm text-gray-400 font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{showHardware ? 'Hide' : 'Show'} options
</span>
</button>
@@ -209,81 +242,84 @@ export function Step4VoIP({
return (
<div
key={hardware.id}
className={`p-4 rounded-lg border-2 transition-all ${
className={`p-4 rounded-xl border-2 transition-all duration-200 ${
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>
<div className="space-y-3">
<div>
<h4 className="font-semibold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{hardware.name}
</h4>
<p className="text-xs sm:text-sm text-gray-400">{hardware.description}</p>
<div className="flex gap-3 sm:gap-4 mt-2 text-xs sm:text-sm">
<span className="text-gray-500">
Buy: <strong className="text-[#333d49]">{formatCurrency(hardware.oneTimePrice)}</strong>
</span>
<span className="text-[#333d49]">
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
<span className="text-gray-500">
Rent: <strong className="text-[#333d49]">{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">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
className={`px-2 py-1 text-xs rounded ${
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Buy
</button>
<button
type="button"
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
className={`px-2 py-1 text-xs rounded ${
className={`px-2.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Rent
</button>
</div>
{/* Quantity */}
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, -1)}
className="p-2 hover:bg-gray-100 rounded-l-lg"
className="p-2 hover:bg-gray-50 rounded-l-lg transition-colors"
disabled={selection.quantity <= 1}
>
<Minus className="w-4 h-4" />
<Minus className="w-3.5 h-3.5" />
</button>
<span className="w-8 text-center font-medium">
<span className="w-8 text-center font-semibold text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{selection.quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(hardware.id, 1)}
className="p-2 hover:bg-gray-100 rounded-r-lg"
className="p-2 hover:bg-gray-50 rounded-r-lg transition-colors"
>
<Plus className="w-4 h-4" />
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Remove */}
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
className="p-2 text-red-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
<X className="w-4 h-4" />
</button>
@@ -317,21 +353,29 @@ export function Step4VoIP({
{/* 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" />
<ul className="space-y-2.5">
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<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" />
<li className="flex items-start gap-2.5">
<div className="w-4 h-4 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2.5 h-2.5 text-[#fe7400]" strokeWidth={3} />
</div>
<span>Keep your existing phone numbers</span>
</li>
</ul>
@@ -339,18 +383,19 @@ export function Step4VoIP({
{/* 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">
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">VoIP Monthly Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</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]">
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Hardware Purchase (One-Time)</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getVoIPOneTime())}
</span>
</div>
@@ -364,10 +409,10 @@ export function Step4VoIP({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-8 text-gray-500"
className="text-center py-12 text-gray-400"
>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>You can always add VoIP services later.</p>
<Phone className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">You can always add VoIP services later.</p>
</motion.div>
)}
</motion.div>

View File

@@ -47,28 +47,50 @@ export function Step5WebEmail({
transition={{ duration: 0.3 }}
className="space-y-8"
>
{/* Service Explainer */}
<div className="space-y-2">
<p className="text-sm sm:text-base text-gray-500 leading-relaxed">
<strong className="text-[#333d49]">Web hosting and email</strong> are often managed
separately from IT, but bundling them with your MSP means one point of contact for
everything. We handle the technical details &mdash; SSL certificates, backups, security
updates, DNS, and spam filtering &mdash; so you don't have to.
</p>
<p className="text-sm text-gray-400 leading-relaxed">
For email, choose between our budget-friendly self-hosted option (great for basic
email needs) or Microsoft 365, which includes Outlook, Teams, OneDrive, and the
full Office suite. Both options include professional yourname@yourcompany.com addresses
and spam protection.
</p>
</div>
{/* 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">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Globe className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Web Hosting
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Managed WordPress hosting with SSL and backups
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<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">
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 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-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{webHostingSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
@@ -83,7 +105,7 @@ export function Step5WebEmail({
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{webHostingTiers.map((tier, index) => {
const isSelected = webHostingSelection.tierId === tier.id;
@@ -93,46 +115,51 @@ export function Step5WebEmail({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</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>
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-3">{tier.description}</p>
<div className="mb-3">
<span className="text-2xl font-bold text-[#333d49]">
<span className="text-2xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(tier.monthlyPrice)}
</span>
<span className="text-gray-500 text-sm">/mo</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
<div className="flex gap-3 mb-3 text-xs text-gray-600">
<div className="flex gap-3 mb-3 text-xs text-gray-400 font-medium">
<span>{tier.storage}</span>
<span>|</span>
<span className="text-gray-300">|</span>
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
</div>
<ul className="space-y-1 mb-4">
<ul className="space-y-1.5 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 key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
@@ -156,30 +183,36 @@ export function Step5WebEmail({
</div>
{/* Divider */}
<div className="border-t border-gray-200" />
<div className="border-t border-gray-100" />
{/* 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">
<div className="bg-[#f8f9fb] rounded-xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-[#fe7400]/8 flex items-center justify-center flex-shrink-0">
<Mail className="w-4 h-4 sm:w-5 sm:h-5 text-[#fe7400]" />
</div>
<div className="min-w-0">
<h3 className="font-bold text-[#333d49] text-sm sm:text-base"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Email Service
</h3>
<p className="text-xs sm:text-sm text-gray-400">
Professional business email hosting
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
<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">
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/10 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-semibold text-gray-500"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{emailSelection.enabled ? 'Yes' : 'No'}
</span>
</label>
@@ -196,8 +229,9 @@ export function Step5WebEmail({
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]">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-4 border border-gray-200/80 rounded-xl bg-white shadow-card">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Number of mailboxes:
</label>
<Input
@@ -205,7 +239,7 @@ export function Step5WebEmail({
min={1}
value={emailSelection.mailboxCount}
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
className="w-24"
className="w-full sm:w-24"
/>
</div>
@@ -213,44 +247,51 @@ export function Step5WebEmail({
<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 ${
className={`p-5 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
emailSelection.provider === 'whm'
? 'border-[#fe7400] bg-[#fe7400]/5'
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: '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>
<h4 className="font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Self-Hosted (WHM)
</h4>
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-400">
Budget-friendly email hosting on our servers
</p>
</div>
<div
onClick={() => onSetEmailProvider('m365')}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
className={`p-5 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
emailSelection.provider === 'm365'
? 'border-[#fe7400] bg-[#fe7400]/5'
? 'border-[#fe7400] bg-[#fe7400]/5 shadow-sm'
: '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">
<h4 className="font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Microsoft 365
</h4>
<span className="text-[11px] bg-gradient-accent text-white px-2 py-0.5 rounded-md font-bold uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Recommended
</span>
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-400">
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">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
{(emailSelection.provider === 'whm' ? whmTiers : m365Tiers).map((tier, index) => {
const isSelected = emailSelection.tierId === tier.id;
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
@@ -261,43 +302,48 @@ export function Step5WebEmail({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
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 className="bg-gradient-accent text-white text-[11px] font-bold px-3 py-1.5 text-center uppercase tracking-wider"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Popular
</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>
<h3 className="text-base font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{tier.name}
</h3>
<p className="text-xs text-gray-400 mb-2">{tier.storage}</p>
<div className="mb-3">
<span className="text-xl font-bold text-[#333d49]">
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</span>
<span className="text-gray-500 text-xs">/mo</span>
<span className="text-gray-400 text-xs">/mo</span>
<p className="text-xs text-gray-400">
{formatCurrency(tier.pricePerMailbox)}/mailbox
</p>
</div>
<ul className="space-y-1 mb-3">
<ul className="space-y-1.5 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 key={idx} className="flex items-start gap-2 text-xs">
<div className="w-3.5 h-3.5 rounded-full bg-[#fe7400]/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-2 h-2 text-[#fe7400]" strokeWidth={3} />
</div>
<span className="text-gray-500">{feature}</span>
</li>
))}
</ul>
@@ -322,17 +368,23 @@ export function Step5WebEmail({
{/* Info */}
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
<div className="space-y-3">
<div className="space-y-4">
<div>
<h5 className="font-medium text-[#333d49]">Self-Hosted (WHM)</h5>
<p className="text-sm text-gray-600">
<h5 className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Self-Hosted (WHM)
</h5>
<p className="text-sm text-gray-500 mt-1">
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">
<h5 className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Microsoft 365
</h5>
<p className="text-sm text-gray-500 mt-1">
Best for businesses that need collaboration tools. Includes Outlook,
Teams for video calls, OneDrive cloud storage, and the full Office
suite (Word, Excel, PowerPoint).
@@ -344,31 +396,33 @@ export function Step5WebEmail({
{/* 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]">
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Web Hosting</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getWebHostingMonthly())}
<span className="text-sm font-normal">/mo</span>
<span className="text-sm font-medium text-gray-400 ml-1">/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]">
<div className="bg-[#f8f9fb] rounded-xl p-4 flex items-center justify-between border border-gray-200/80">
<span className="text-gray-500 font-medium">Email Service</span>
<span className="text-xl font-bold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getEmailMonthly())}
<span className="text-sm font-normal">/mo</span>
<span className="text-sm font-medium text-gray-400 ml-1">/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">
<div className="bg-gradient-dark text-white rounded-xl p-4 sm:p-5 flex items-center justify-between gap-3">
<span className="text-sm sm:text-base font-medium opacity-90">Web & Email Total</span>
<span className="text-2xl sm:text-3xl font-bold whitespace-nowrap" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(getWebHostingMonthly() + getEmailMonthly())}
<span className="text-lg font-normal opacity-75">/month</span>
<span className="text-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign } from 'lucide-react';
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui';
import {
gpsTiers,
@@ -25,7 +25,6 @@ export function Step6Summary({
onGoToStep,
onCalculateQuote,
}: Step6SummaryProps) {
// Calculate fresh quote if not available
const result = quoteResult || onCalculateQuote();
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
@@ -38,7 +37,8 @@ export function Step6Summary({
const emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
const handlePrint = () => {
window.print();
// Brief delay to ensure print-only elements render
requestAnimationFrame(() => window.print());
};
return (
@@ -48,19 +48,42 @@ export function Step6Summary({
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Print-only branded header */}
<div className="hidden print-show mb-6" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<div className="flex items-center justify-between pb-4 border-b-2 border-[#fe7400]">
<div>
<h1 className="text-2xl font-bold text-[#333d49]">Arizona Computer Guru</h1>
<p className="text-sm text-gray-400">Managed IT Services Quote</p>
</div>
<div className="text-right text-sm text-gray-400">
<p>{new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
<p>Valid for 30 days</p>
</div>
</div>
</div>
{/* 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 className="text-center mb-8 print-hide">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Your Quote Summary
</h2>
<p className="text-gray-400">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>
<div className="bg-[#f8f9fb] rounded-xl p-5 mb-6 border border-gray-200/50">
<p className="text-[11px] text-gray-400 mb-1 uppercase tracking-wider font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Quote prepared for
</p>
<p className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{quoteData.company.name}
</p>
{quoteData.company.industry && (
<p className="text-sm text-gray-600">{quoteData.company.industry}</p>
<p className="text-sm text-gray-400">{quoteData.company.industry}</p>
)}
</div>
)}
@@ -94,13 +117,20 @@ export function Step6Summary({
onEdit={() => onGoToStep(2)}
>
<div className="space-y-2">
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
{quoteData.support.planId === 'none' ? (
<SummaryLine
label="No Monthly Plan (pay-as-you-go)"
value="$0"
/>
) : (
<SummaryLine
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
value={formatCurrency(result.breakdown.support.plan)}
/>
)}
{blockTime && (
<SummaryLine
label={`Block Time (${blockTime.hours} hours)`}
label={`Block Time (${blockTime.hours} hours) — one-time`}
value={formatCurrency(result.breakdown.support.blockTime)}
/>
)}
@@ -160,41 +190,42 @@ export function Step6Summary({
</SummarySection>
)}
{/* Totals */}
{/* Grand Total */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="bg-[#333d49] text-white rounded-xl p-6 mt-8"
className="bg-gradient-navy text-white rounded-2xl p-6 sm:p-8 mt-8"
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg">Monthly Total</span>
<span className="text-4xl font-bold">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 mb-5">
<span className="text-base sm:text-lg font-medium text-white/80">Monthly Investment</span>
<span className="text-3xl sm:text-4xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}
<span className="text-lg font-normal opacity-75">/mo</span>
<span className="text-sm sm:text-base font-medium text-white/50 ml-1">/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">
<div className="flex items-center justify-between py-4 border-t border-white/10">
<span className="text-white/60">One-Time Costs</span>
<span className="text-xl font-bold" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{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">
<div className="pt-4 border-t border-white/10">
<div className="flex items-center justify-between text-sm text-white/50">
<span>Annual Investment</span>
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
<span className="font-medium">{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">
<div className="bg-white rounded-xl border border-gray-200/80 shadow-card p-5 sm:p-6">
<h4 className="font-bold text-[#333d49] mb-5 flex items-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<DollarSign className="w-5 h-5 text-[#fe7400]" />
Monthly Breakdown
</h4>
@@ -210,19 +241,25 @@ export function Step6Summary({
{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 className="pt-4 mt-1 border-t-2 border-[#fe7400]/20 flex justify-between items-center">
<span className="font-bold text-[#333d49] text-lg"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Total
</span>
<span className="font-bold text-[#fe7400] text-xl"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(result.monthlyTotal)}/mo
</span>
</div>
</div>
</div>
{/* Print Button */}
<div className="flex justify-center pt-4 print:hidden">
<div className="flex justify-center pt-2 print-hide">
<Button
variant="outline"
variant="ghost"
onClick={handlePrint}
className="flex items-center gap-2"
className="flex items-center gap-2 text-gray-400"
>
<Printer className="w-4 h-4" />
Print Quote
@@ -230,10 +267,15 @@ export function Step6Summary({
</div>
{/* Notes Section */}
<div className="text-center text-sm text-gray-500 pt-4">
<div className="text-center text-xs text-gray-400 pt-2 space-y-1">
<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>
{/* Print-only footer */}
<div className="hidden print-show mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-400">
<p>Arizona Computer Guru &middot; azcomputerguru.com &middot; (480) 400-3798</p>
</div>
</motion.div>
);
}
@@ -251,30 +293,36 @@ interface SummarySectionProps {
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="border border-gray-200 rounded-lg overflow-hidden"
className="border border-gray-200/80 rounded-xl overflow-hidden bg-white shadow-card print-section"
>
<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 className="bg-[#f8f9fb] px-4 sm:px-5 py-3 sm:py-3.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<span className="text-[#fe7400] flex-shrink-0">{icon}</span>
<span className="font-bold text-[#333d49] text-sm sm:text-base truncate"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{title}
</span>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-[#333d49]">
{formatCurrency(monthlyTotal)}/mo
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<span className="font-bold text-[#333d49] text-sm sm:text-base whitespace-nowrap"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyTotal)}
<span className="text-xs font-medium text-gray-400 ml-0.5">/mo</span>
</span>
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 text-sm text-[#fe7400] hover:text-[#e56800] transition-colors print:hidden"
className="flex items-center gap-1 sm:gap-1.5 text-xs sm:text-sm text-[#fe7400] hover:text-[#e56800] font-semibold transition-colors print-hide"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
<Edit2 className="w-3 h-3" />
<Edit2 className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
Edit
</button>
</div>
</div>
<div className="p-4">{children}</div>
<div className="p-5">{children}</div>
</motion.div>
);
}
@@ -286,9 +334,15 @@ interface SummaryLineProps {
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 className="flex justify-between items-center text-sm">
<span className="text-gray-500 flex items-center gap-2">
<ArrowRight className="w-3 h-3 text-gray-300" />
{label}
</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{value}
</span>
</div>
);
}
@@ -300,9 +354,12 @@ interface BreakdownRowProps {
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 className="flex justify-between items-center py-1">
<span className="text-gray-500">{label}</span>
<span className="font-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(value)}
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } from 'lucide-react';
import { User, Mail, Phone, MessageSquare, Shield, Clock, Sparkles } from 'lucide-react';
import { Input, Button } from '@/components/ui';
import { contactPreferences } from '@/lib/pricing-data';
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
@@ -36,7 +36,6 @@ export function Step7Contact({
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 });
}
@@ -77,7 +76,6 @@ export function Step7Contact({
if (validateForm()) {
onSubmit();
} else {
// Mark all fields as touched to show errors
setTouched({
name: true,
email: true,
@@ -95,29 +93,39 @@ export function Step7Contact({
>
{/* 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">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Get Your Quote
</h2>
<p className="text-gray-400">
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
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-navy rounded-xl p-4 sm:p-5 mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
>
<span className="text-sm sm:text-base text-white/80 font-medium">Your Estimated Monthly Total</span>
<span className="text-xl sm:text-2xl font-bold text-white"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quoteResult.monthlyTotal)}
<span className="text-xs sm:text-sm font-medium text-white/50 ml-1">/mo</span>
</span>
</div>
</motion.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]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="text"
@@ -131,10 +139,11 @@ export function Step7Contact({
{/* Email */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="email"
@@ -148,10 +157,11 @@ export function Step7Contact({
{/* Phone */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-400 font-normal">(recommended)</span>
<span className="text-gray-300 font-normal text-xs">(recommended)</span>
</label>
<Input
type="tel"
@@ -161,46 +171,34 @@ export function Step7Contact({
/>
</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]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-400 font-normal">(optional)</span>
<span className="text-gray-300 font-normal text-xs">(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"
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 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]">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Preferred Contact Method
</label>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4 sm:gap-5">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2 cursor-pointer"
className="flex items-center gap-2.5 cursor-pointer group"
>
<input
type="radio"
@@ -210,7 +208,9 @@ export function Step7Contact({
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>
<span className="text-sm text-gray-500 group-hover:text-gray-700 transition-colors">
{pref.label}
</span>
</label>
))}
</div>
@@ -218,7 +218,7 @@ export function Step7Contact({
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={contactInfo.agreedToTerms}
@@ -228,18 +228,18 @@ export function Step7Contact({
}}
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-500 leading-relaxed">
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">
<a href="/privacy" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:underline">
<a href="/terms" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Terms of Service
</a>
.
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs ml-0.5">*</span>
</span>
</label>
{touched.agreedToTerms && errors.agreedToTerms && (
@@ -258,11 +258,16 @@ export function Step7Contact({
type="submit"
variant="primary"
size="lg"
className="w-full text-lg py-4"
className="w-full text-base py-4"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
{isSubmitting ? 'Submitting...' : (
<>
<Sparkles className="w-5 h-5 mr-2" />
Submit Quote Request
</>
)}
</Button>
</motion.div>
</form>
@@ -272,20 +277,26 @@ export function Step7Contact({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-8 pt-6 border-t border-gray-200"
className="mt-10 pt-6 border-t border-gray-100"
>
<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 className="flex flex-col sm:flex-row sm:justify-between gap-4 sm:gap-5">
<div className="flex items-center gap-3 justify-center sm:justify-start">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">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 className="flex items-center gap-3 justify-center">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Clock className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">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 className="flex items-center gap-3 justify-center sm:justify-end">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Your data is secure</span>
</div>
</div>
</motion.div>

View File

@@ -0,0 +1,286 @@
import { motion, AnimatePresence } from 'framer-motion';
import {
Monitor,
Headphones,
Phone,
Globe,
Mail,
ShieldCheck,
ChevronRight,
} from 'lucide-react';
import type { ServiceInterests } from '@/types/quote';
export interface StepServiceDiscoveryProps {
serviceInterests: ServiceInterests;
onSetServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
}
interface ServiceCardDef {
key: keyof ServiceInterests;
icon: typeof Monitor;
title: string;
tagline: string;
description: string;
highlights: string[];
core?: boolean;
}
const serviceCards: ServiceCardDef[] = [
{
key: 'gps',
icon: Monitor,
title: 'Managed IT & Monitoring',
tagline: 'Core Service',
description:
"Our Guru Protection Suite provides 24/7 endpoint monitoring, automated patch management, antivirus, and proactive security — so issues get resolved before they impact your business.",
highlights: [
'Remote monitoring & management',
'Patch management & antivirus',
'Proactive security alerts',
],
core: true,
},
{
key: 'support',
icon: Headphones,
title: 'Help Desk & Support',
tagline: 'Labor Packages',
description:
"From pay-as-you-go to unlimited plans, our help desk gives you access to real technicians who know your environment. Remote support, on-site visits, and pre-purchased block time available.",
highlights: [
'Help desk & remote support',
'On-site technician visits',
'Pre-purchased block time savings',
],
},
{
key: 'voip',
icon: Phone,
title: 'VoIP Phone System',
tagline: 'Business Communications',
description:
"Modern cloud phone system with HD voice, video conferencing, mobile apps, and advanced call management. Hardware options from desk phones to wireless headsets.",
highlights: [
'Cloud-based phone system',
'Video conferencing & mobile app',
'Hardware rental or purchase',
],
},
{
key: 'webHosting',
icon: Globe,
title: 'Web Hosting',
tagline: 'Managed Hosting',
description:
"Secure, fast web hosting with free SSL certificates, automated backups, and staging environments. From a single site to unlimited — we manage the infrastructure so you don't have to.",
highlights: [
'Managed hosting with SSL & backups',
'Staging environments',
'Performance optimization & CDN',
],
},
{
key: 'email',
icon: Mail,
title: 'Email Services',
tagline: 'Business Email & Security',
description:
"Business email powered by Microsoft 365 or our hosted platform. Add advanced spam filtering, phishing simulations, security awareness training, and email archiving.",
highlights: [
'Microsoft 365 or hosted email',
'Advanced spam & phishing protection',
'Security training & compliance',
],
},
];
const stagger = {
hidden: {},
visible: { transition: { staggerChildren: 0.07 } },
};
const cardVariant = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const },
},
};
export function StepServiceDiscovery({
serviceInterests,
onSetServiceInterest,
}: StepServiceDiscoveryProps) {
const selectedCount = Object.values(serviceInterests).filter(Boolean).length;
return (
<motion.div
variants={stagger}
initial="hidden"
animate="visible"
className="space-y-8"
>
{/* Header */}
<motion.div variants={cardVariant} className="text-center max-w-2xl mx-auto">
<h2
className="text-2xl sm:text-3xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
What services interest you?
</h2>
<p className="text-gray-400 text-sm sm:text-base leading-relaxed">
Toggle the services you&rsquo;d like to explore. We&rsquo;ll customize the rest of
your experience based on your selections.
</p>
</motion.div>
{/* Service cards */}
<motion.div variants={stagger} className="space-y-3">
{serviceCards.map((card) => {
const isActive = serviceInterests[card.key];
return (
<motion.div
key={card.key}
variants={cardVariant}
layout
className={`
relative rounded-2xl border-2 transition-all duration-300 overflow-hidden
${isActive
? 'border-[#fe7400]/30 bg-white shadow-[0_2px_12px_rgba(254,116,0,0.08)]'
: 'border-gray-200/60 bg-white/60 hover:border-gray-300'
}
`}
>
{/* Card header — always visible, acts as toggle */}
<button
type="button"
onClick={() => {
if (!card.core) {
onSetServiceInterest(card.key, !isActive);
}
}}
className={`
w-full flex items-center gap-4 px-5 py-4 sm:px-6 sm:py-5 text-left
${card.core ? 'cursor-default' : 'cursor-pointer'}
`}
>
{/* Icon */}
<div
className={`
flex items-center justify-center w-10 h-10 sm:w-11 sm:h-11 rounded-xl flex-shrink-0
transition-colors duration-300
${isActive ? 'bg-[#fe7400]/10' : 'bg-gray-100'}
`}
>
<card.icon
className={`
w-5 h-5 transition-colors duration-300
${isActive ? 'text-[#fe7400]' : 'text-gray-400'}
`}
/>
</div>
{/* Title & tagline */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3
className={`
text-base sm:text-lg font-bold transition-colors duration-300
${isActive ? 'text-[#333d49]' : 'text-gray-400'}
`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{card.title}
</h3>
{card.core && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#fe7400]/10 text-[#fe7400] text-[10px] font-bold uppercase tracking-wide">
<ShieldCheck className="w-3 h-3" />
Core
</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">{card.tagline}</p>
</div>
{/* Toggle switch */}
<div className="flex-shrink-0">
{card.core ? (
<div className="flex items-center gap-1.5 text-xs font-medium text-[#059669]">
<ShieldCheck className="w-3.5 h-3.5" />
Included
</div>
) : (
<div
className={`
relative w-12 h-7 rounded-full transition-colors duration-300
${isActive ? 'bg-[#fe7400]' : 'bg-gray-200'}
`}
>
<motion.div
className="absolute top-0.5 w-6 h-6 rounded-full bg-white shadow-sm"
animate={{ left: isActive ? '22px' : '2px' }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
</div>
)}
</div>
</button>
{/* Expanded detail — shows when active */}
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
className="overflow-hidden"
>
<div className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0">
<div className="pl-14 sm:pl-[60px]">
{/* Subtle separator */}
<div className="w-12 h-[2px] bg-[#fe7400]/20 rounded-full mb-3" />
<p className="text-sm text-gray-500 leading-relaxed mb-3">
{card.description}
</p>
<ul className="space-y-1.5">
{card.highlights.map((h) => (
<li key={h} className="flex items-center gap-2 text-sm text-gray-500">
<ChevronRight className="w-3 h-3 text-[#fe7400] flex-shrink-0" />
{h}
</li>
))}
</ul>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</motion.div>
{/* Selection summary */}
<motion.div
variants={cardVariant}
className="text-center pt-2"
>
<p className="text-sm text-gray-400">
<span className="font-semibold text-[#fe7400]">{selectedCount}</span>
{selectedCount === 1 ? ' service' : ' services'} selected
{selectedCount > 0 && (
<span className="text-gray-300 mx-1.5">&middot;</span>
)}
{selectedCount > 0 && (
<span>Click Continue to configure each one</span>
)}
</p>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,350 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Building2,
User,
Monitor,
Headphones,
ArrowRight,
Shield,
Clock,
Sparkles,
} from 'lucide-react';
import type {
ClientType,
CompanyInfo,
ContactInfo,
Industry,
} from '@/types/quote';
export interface StepWelcomeProps {
clientType: ClientType;
companyInfo: CompanyInfo;
contactInfo: ContactInfo;
onSetClientType: (type: ClientType) => void;
onUpdateCompany: (data: Partial<CompanyInfo>) => void;
onUpdateContact: (data: Partial<ContactInfo>) => void;
onSetEndpointCount: (count: number) => void;
onSetIndustry: (industry: Industry | '') => void;
}
const industries: Industry[] = [
'Healthcare',
'Legal',
'Finance',
'Manufacturing',
'Retail',
'Professional Services',
'Other',
];
const journeySteps = [
{
icon: Sparkles,
title: 'Tell us about yourself',
desc: 'Basic info so we can personalize your experience',
},
{
icon: Monitor,
title: 'Choose your services',
desc: 'Toggle the IT services that interest you',
},
{
icon: Headphones,
title: 'Configure each service',
desc: "We'll walk through your selections one by one",
},
{
icon: ArrowRight,
title: 'Review & submit',
desc: 'Get your custom quote delivered instantly',
},
];
const stagger = {
hidden: {},
visible: { transition: { staggerChildren: 0.06 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] as const } },
};
export function StepWelcome({
clientType,
companyInfo,
contactInfo,
onSetClientType,
onUpdateCompany,
onUpdateContact,
onSetEndpointCount,
onSetIndustry,
}: StepWelcomeProps) {
const [endpointInput, setEndpointInput] = useState(String(companyInfo.endpointCount));
const handleEndpointChange = (val: string) => {
setEndpointInput(val);
const num = parseInt(val, 10);
if (!isNaN(num) && num >= 1) {
onSetEndpointCount(num);
}
};
return (
<motion.div
variants={stagger}
initial="hidden"
animate="visible"
className="space-y-10"
>
{/* Hero welcome */}
<motion.div variants={fadeUp} className="text-center max-w-2xl mx-auto">
<h2
className="text-3xl sm:text-4xl font-bold text-[#333d49] mb-3 leading-tight"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Let&rsquo;s Build Your
<span className="text-[#fe7400]"> IT Solution</span>
</h2>
<p className="text-gray-400 text-base sm:text-lg leading-relaxed max-w-lg mx-auto">
In just a few minutes, we&rsquo;ll create a custom technology package
tailored to your needs. No commitment required.
</p>
</motion.div>
{/* What to expect */}
<motion.div variants={fadeUp}>
<div className="bg-gradient-to-br from-[#f8f9fb] to-[#f1f3f5] rounded-2xl p-5 sm:p-6">
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
What to expect
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{journeySteps.map((step, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className="flex items-center justify-center w-6 h-6 rounded-full bg-[#fe7400]/10 text-[#fe7400] text-[10px] font-bold flex-shrink-0"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{i + 1}
</span>
<step.icon className="w-3.5 h-3.5 text-gray-400" />
</div>
<p
className="text-sm font-semibold text-[#333d49] leading-snug"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{step.title}
</p>
<p className="text-xs text-gray-400 leading-relaxed">{step.desc}</p>
</div>
))}
</div>
</div>
</motion.div>
{/* Client type toggle */}
<motion.div variants={fadeUp}>
<label
className="block text-sm font-semibold text-[#333d49] mb-3"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
I&rsquo;m looking for IT services for&hellip;
</label>
<div className="inline-flex bg-[#f1f3f5] rounded-xl p-1 gap-1">
{(['company', 'individual'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => onSetClientType(type)}
className={`
relative flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200
${clientType === type
? 'bg-white text-[#333d49] shadow-sm'
: 'text-gray-400 hover:text-gray-500'
}
`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
{type === 'company' ? (
<Building2 className="w-4 h-4" />
) : (
<User className="w-4 h-4" />
)}
{type === 'company' ? 'A Business' : 'Myself'}
</button>
))}
</div>
</motion.div>
{/* Contact & company info form */}
<motion.div variants={fadeUp} className="space-y-6">
{/* Contact info */}
<div>
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Your contact information
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Your Name <span className="text-[#fe7400]">*</span>
</label>
<input
type="text"
value={contactInfo.name}
onChange={(e) => onUpdateContact({ name: e.target.value })}
placeholder="First and last name"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Email <span className="text-[#fe7400]">*</span>
</label>
<input
type="email"
value={contactInfo.email}
onChange={(e) => onUpdateContact({ email: e.target.value })}
placeholder="you@company.com"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Phone <span className="text-gray-300 text-xs font-normal">(recommended)</span>
</label>
<input
type="tel"
value={contactInfo.phone}
onChange={(e) => onUpdateContact({ phone: e.target.value })}
placeholder="(480) 555-0100"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</div>
{/* Company name — only for business clients */}
<AnimatePresence mode="wait">
{clientType === 'company' && (
<motion.div
key="company-name"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Company Name
</label>
<input
type="text"
value={companyInfo.name}
onChange={(e) => onUpdateCompany({ name: e.target.value })}
placeholder="Acme Corp"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
placeholder:text-gray-300 focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Business details */}
<div>
<p
className="text-[11px] uppercase tracking-widest font-semibold text-gray-400 mb-4"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
About your environment
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Devices / Endpoints <span className="text-[#fe7400]">*</span>
</label>
<div className="flex items-center gap-3">
<input
type="number"
min={1}
value={endpointInput}
onChange={(e) => handleEndpointChange(e.target.value)}
onBlur={() => {
const num = parseInt(endpointInput, 10);
if (isNaN(num) || num < 1) {
setEndpointInput('1');
onSetEndpointCount(1);
}
}}
className="w-24 px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm text-center
focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none"
/>
<span className="text-sm text-gray-400">
computers, laptops, & servers
</span>
</div>
</div>
<AnimatePresence mode="wait">
{clientType === 'company' && (
<motion.div
key="industry"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<label className="block text-sm font-medium text-[#333d49] mb-1.5">
Industry
</label>
<select
value={companyInfo.industry}
onChange={(e) => onSetIndustry(e.target.value as Industry | '')}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 bg-white text-[#333d49] text-sm
focus:border-[#fe7400] focus:ring-2 focus:ring-[#fe7400]/10
transition-all duration-200 outline-none appearance-none cursor-pointer"
>
<option value="">Select an industry</option>
{industries.map((ind) => (
<option key={ind} value={ind}>
{ind}
</option>
))}
</select>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
{/* Trust signals */}
<motion.div variants={fadeUp} className="flex flex-wrap items-center justify-center gap-6 pt-2">
{[
{ icon: Shield, text: 'No obligation' },
{ icon: Clock, text: 'Takes ~2 minutes' },
{ icon: Sparkles, text: 'Instant quote' },
].map(({ icon: Icon, text }) => (
<span key={text} className="flex items-center gap-1.5 text-xs text-gray-400">
<Icon className="w-3.5 h-3.5" />
{text}
</span>
))}
</motion.div>
</motion.div>
);
}

View File

@@ -1,3 +1,5 @@
export { StepWelcome, type StepWelcomeProps } from './StepWelcome';
export { StepServiceDiscovery, type StepServiceDiscoveryProps } from './StepServiceDiscovery';
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type {
QuoteData,
QuoteResult,
@@ -19,6 +19,8 @@ import type {
EmailProvider,
Industry,
ContactPreference,
ClientType,
ServiceInterests,
} from '@/types/quote';
import {
gpsTiers,
@@ -31,9 +33,46 @@ import {
emailTiers,
} from '@/lib/pricing-data';
const DRAFT_STORAGE_KEY = 'quote-wizard-draft';
/**
* Load saved draft from localStorage if available.
* Returns partial state keyed by section, or null if nothing saved.
*/
function loadDraft(): {
clientType?: ClientType;
serviceInterests?: ServiceInterests;
company?: CompanyInfo;
gps?: GPSSelection;
support?: SupportSelection;
voip?: VoIPSelection;
webHosting?: WebHostingSelection;
email?: EmailSelection;
contact?: ContactInfo;
accessToken?: string;
} | null {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Initial state values
*/
const initialClientType: ClientType = 'company';
const initialServiceInterests: ServiceInterests = {
gps: true,
support: true,
voip: false,
webHosting: false,
email: false,
};
const initialCompanyInfo: CompanyInfo = {
name: '',
endpointCount: 10,
@@ -90,6 +129,10 @@ export interface UseQuoteReturn {
quoteData: QuoteData;
quoteResult: QuoteResult | null;
// Client type & service interests
setClientType: (type: ClientType) => void;
setServiceInterest: (service: keyof ServiceInterests, enabled: boolean) => void;
// Company updates
updateCompany: (data: Partial<CompanyInfo>) => void;
setEndpointCount: (count: number) => void;
@@ -140,6 +183,7 @@ export interface UseQuoteReturn {
getVoIPMonthly: () => number;
getWebHostingMonthly: () => number;
getEmailMonthly: () => number;
getSupportBlockTimeOneTime: () => number;
getVoIPOneTime: () => number;
// Reset
@@ -150,18 +194,67 @@ export interface UseQuoteReturn {
* Quote calculation and state management hook
*/
export function useQuote(): UseQuoteReturn {
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
const draft = useRef(loadDraft());
const [clientType, setClientType] = useState<ClientType>(draft.current?.clientType ?? initialClientType);
const [serviceInterests, setServiceInterests] = useState<ServiceInterests>(draft.current?.serviceInterests ?? initialServiceInterests);
const [company, setCompany] = useState<CompanyInfo>(draft.current?.company ?? initialCompanyInfo);
const [gps, setGPS] = useState<GPSSelection>(draft.current?.gps ?? initialGPSSelection);
const [support, setSupport] = useState<SupportSelection>(draft.current?.support ?? initialSupportSelection);
const [voip, setVoIP] = useState<VoIPSelection>(draft.current?.voip ?? initialVoIPSelection);
const [webHosting, setWebHosting] = useState<WebHostingSelection>(draft.current?.webHosting ?? initialWebHostingSelection);
const [email, setEmail] = useState<EmailSelection>(draft.current?.email ?? initialEmailSelection);
const [contact, setContact] = useState<ContactInfo>(draft.current?.contact ?? initialContactInfo);
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
// Persist draft to localStorage when any section changes (debounced)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
try {
// Preserve the accessToken that WizardContainer may have written
const existing = localStorage.getItem(DRAFT_STORAGE_KEY);
let accessToken: string | undefined;
if (existing) {
try {
accessToken = JSON.parse(existing).accessToken;
} catch {
// ignore
}
}
const payload = {
clientType,
serviceInterests,
company,
gps,
support,
voip,
webHosting,
email,
contact,
...(accessToken ? { accessToken } : {}),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// localStorage write failures are non-critical
}
}, 500);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, [clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]);
// Combined quote data
const quoteData: QuoteData = useMemo(
() => ({
clientType,
serviceInterests,
company,
gps,
support,
@@ -170,9 +263,31 @@ export function useQuote(): UseQuoteReturn {
email,
contact,
}),
[company, gps, support, voip, webHosting, email, contact]
[clientType, serviceInterests, company, gps, support, voip, webHosting, email, contact]
);
// ============================================================================
// Client Type & Service Interests
// ============================================================================
const setClientTypeValue = useCallback((type: ClientType) => {
setClientType(type);
}, []);
const setServiceInterest = useCallback((service: keyof ServiceInterests, enabled: boolean) => {
setServiceInterests((prev) => ({ ...prev, [service]: enabled }));
// Sync the enabled flags on the corresponding selections
if (service === 'voip') {
setVoIP((prev) => ({ ...prev, enabled, userCount: enabled ? Math.max(prev.userCount, 1) : 0 }));
}
if (service === 'webHosting') {
setWebHosting((prev) => ({ ...prev, enabled }));
}
if (service === 'email') {
setEmail((prev) => ({ ...prev, enabled, mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0 }));
}
}, []);
// ============================================================================
// Company Updates
// ============================================================================
@@ -387,19 +502,16 @@ export function useQuote(): UseQuoteReturn {
}, [gps]);
const getSupportMonthly = useCallback((): number => {
if (support.planId === 'none') return 0;
const plan = supportPlans.find((p) => p.id === support.planId);
if (!plan) return 0;
return plan ? plan.monthlyPrice : 0;
}, [support]);
let total = plan.monthlyPrice;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
total += blockTime.price;
}
}
return total;
const getSupportBlockTimeOneTime = useCallback((): number => {
if (!support.useBlockTime || !support.blockTimeId) return 0;
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
return blockTime ? blockTime.price : 0;
}, [support]);
const getVoIPMonthly = useCallback((): number => {
@@ -460,6 +572,7 @@ export function useQuote(): UseQuoteReturn {
const supportMonthly = getSupportMonthly();
const voipMonthly = getVoIPMonthly();
const voipOneTime = getVoIPOneTime();
const supportBlockTimeOneTime = getSupportBlockTimeOneTime();
const webHostingMonthly = getWebHostingMonthly();
const emailMonthly = getEmailMonthly();
@@ -473,15 +586,8 @@ export function useQuote(): UseQuoteReturn {
}
// Calculate support breakdown
const supportPlan = supportPlans.find((p) => p.id === support.planId);
const supportPlan = support.planId !== 'none' ? supportPlans.find((p) => p.id === support.planId) : null;
const supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
let supportBlockTime = 0;
if (support.useBlockTime && support.blockTimeId) {
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
if (blockTime) {
supportBlockTime = blockTime.price;
}
}
// Calculate VoIP breakdown
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
@@ -506,7 +612,7 @@ export function useQuote(): UseQuoteReturn {
},
support: {
plan: supportPlanCost,
blockTime: supportBlockTime,
blockTime: supportBlockTimeOneTime,
total: supportMonthly,
},
voip: {
@@ -522,7 +628,7 @@ export function useQuote(): UseQuoteReturn {
const result: QuoteResult = {
monthlyTotal,
oneTimeTotal: voipOneTime,
oneTimeTotal: voipOneTime + supportBlockTimeOneTime,
breakdown,
gpsMonthly,
supportMonthly,
@@ -533,13 +639,15 @@ export function useQuote(): UseQuoteReturn {
setQuoteResult(result);
return result;
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getSupportBlockTimeOneTime, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
// ============================================================================
// Reset
// ============================================================================
const resetQuote = useCallback(() => {
setClientType(initialClientType);
setServiceInterests(initialServiceInterests);
setCompany(initialCompanyInfo);
setGPS(initialGPSSelection);
setSupport(initialSupportSelection);
@@ -548,12 +656,17 @@ export function useQuote(): UseQuoteReturn {
setEmail(initialEmailSelection);
setContact(initialContactInfo);
setQuoteResult(null);
localStorage.removeItem(DRAFT_STORAGE_KEY);
}, []);
return {
quoteData,
quoteResult,
// Client type & service interests
setClientType: setClientTypeValue,
setServiceInterest,
// Company updates
updateCompany,
setEndpointCount,
@@ -604,6 +717,7 @@ export function useQuote(): UseQuoteReturn {
getVoIPMonthly,
getWebHostingMonthly,
getEmailMonthly,
getSupportBlockTimeOneTime,
getVoIPOneTime,
// Reset

View File

@@ -1,46 +1,28 @@
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import type { WizardStep } from '@/types/quote';
/**
* Wizard steps configuration for the 7-step MSP Quote Wizard
*/
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
{
id: 'company',
title: 'Company Profile',
description: 'Tell us about your business',
},
{
id: 'gps',
title: 'GPS Monitoring',
description: 'Select your monitoring tier',
},
{
id: 'support',
title: 'Support Plan',
description: 'Choose your support level',
},
{
id: 'voip',
title: 'VoIP Phone System',
description: 'Business phone options',
},
{
id: 'web-email',
title: 'Web & Email',
description: 'Hosting and email services',
},
{
id: 'summary',
title: 'Review Quote',
description: 'Review your selections',
},
{
id: 'contact',
title: 'Get Your Quote',
description: 'Submit your information',
},
];
export interface WizardStepDef {
id: string;
title: string;
description: string;
}
/** Map step id from URL hash to step index */
function stepIndexFromHash(steps: WizardStepDef[]): number {
const hash = window.location.hash.replace('#', '');
if (!hash) return 0;
const idx = steps.findIndex((s) => s.id === hash);
return idx >= 0 ? idx : 0;
}
/** Determine which steps should be marked complete based on a restored index */
function restoredCompletedSteps(upToIndex: number): Set<number> {
const set = new Set<number>();
for (let i = 0; i < upToIndex; i++) {
set.add(i);
}
return set;
}
export interface UseWizardReturn {
currentStep: number;
@@ -61,37 +43,103 @@ export interface UseWizardReturn {
getStepByIndex: (index: number) => WizardStep | undefined;
}
export function useWizard(): UseWizardReturn {
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
/**
* Dynamic wizard hook — accepts a step definition array that can change
* as the user enables/disables services in the discovery step.
*/
export function useWizard(stepDefs: WizardStepDef[]): UseWizardReturn {
const initialStep = stepIndexFromHash(stepDefs);
const [currentStep, setCurrentStep] = useState(initialStep);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(
() => restoredCompletedSteps(initialStep)
);
const [canProceed, setCanProceed] = useState(true);
const isPopstateRef = useRef(false);
const prevStepDefsRef = useRef(stepDefs);
const totalSteps = WIZARD_STEPS.length;
const totalSteps = stepDefs.length;
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === totalSteps - 1;
// When stepDefs change (services toggled), keep current position valid
useEffect(() => {
const prevDefs = prevStepDefsRef.current;
prevStepDefsRef.current = stepDefs;
if (prevDefs.length === stepDefs.length) return;
// If current step is beyond new length, clamp it
if (currentStep >= stepDefs.length) {
setCurrentStep(Math.max(0, stepDefs.length - 1));
}
// If a step was removed, try to stay on the same step id
const currentId = prevDefs[currentStep]?.id;
if (currentId) {
const newIndex = stepDefs.findIndex((s) => s.id === currentId);
if (newIndex >= 0 && newIndex !== currentStep) {
setCurrentStep(newIndex);
}
}
}, [stepDefs, currentStep]);
// Sync URL hash when currentStep changes
useEffect(() => {
if (isPopstateRef.current) {
isPopstateRef.current = false;
return;
}
const stepId = stepDefs[currentStep]?.id;
if (stepId) {
const newHash = `#${stepId}`;
if (window.location.hash !== newHash) {
window.history.pushState(null, '', newHash);
}
}
}, [currentStep, stepDefs]);
// Listen for browser back/forward
useEffect(() => {
const handlePopState = () => {
const idx = stepIndexFromHash(stepDefs);
isPopstateRef.current = true;
setCurrentStep(idx);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [stepDefs]);
// Set initial hash if none present
useEffect(() => {
if (!window.location.hash) {
const stepId = stepDefs[0]?.id;
if (stepId) {
window.history.replaceState(null, '', `#${stepId}`);
}
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const steps: WizardStep[] = useMemo(() => {
return WIZARD_STEPS.map((step, index) => ({
return stepDefs.map((step, index) => ({
...step,
isComplete: completedSteps.has(index),
isActive: index === currentStep,
}));
}, [currentStep, completedSteps]);
}, [stepDefs, currentStep, completedSteps]);
const currentStepId = useMemo(() => {
return WIZARD_STEPS[currentStep]?.id || '';
}, [currentStep]);
return stepDefs[currentStep]?.id || '';
}, [currentStep, stepDefs]);
const progress = useMemo(() => {
// Progress based on current step position (0 to 100)
if (totalSteps <= 1) return 100;
return Math.round((currentStep / (totalSteps - 1)) * 100);
}, [currentStep, totalSteps]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
// Allow going back to any previous step
// Only allow going forward to completed steps or the next step
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
setCurrentStep(step);
}
@@ -102,7 +150,6 @@ export function useWizard(): UseWizardReturn {
const nextStep = useCallback(() => {
if (!isLastStep && canProceed) {
// Mark current step as complete when moving forward
setCompletedSteps((prev) => new Set(prev).add(currentStep));
setCurrentStep((prev) => prev + 1);
}
@@ -130,7 +177,8 @@ export function useWizard(): UseWizardReturn {
setCurrentStep(0);
setCompletedSteps(new Set());
setCanProceed(true);
}, []);
window.history.replaceState(null, '', `#${stepDefs[0]?.id}`);
}, [stepDefs]);
const getStepByIndex = useCallback(
(index: number): WizardStep | undefined => {

View File

@@ -1,62 +1,199 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary: #333d49;
--color-primary-light: #3d4856;
--color-accent: #fe7400;
--color-accent-hover: #e56800;
--color-accent-light: #fff4e8;
--color-navy: #113559;
--color-gray-600: #4d4d4d;
--color-navy-light: #1a4370;
--color-gray-50: #f8f9fb;
--color-gray-100: #f1f3f5;
--color-gray-200: #e2e5ea;
--color-gray-300: #cdd2d9;
--color-gray-400: #9aa1ac;
--color-gray-500: #6b7280;
--color-gray-600: #4d5562;
--color-success: #059669;
--color-success-light: #ecfdf5;
--font-family-lexend: 'Lexend', sans-serif;
--font-family-display: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-family-body: 'DM Sans', system-ui, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'DM Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-optical-sizing: auto;
}
body {
font-family: 'DM Sans', system-ui, sans-serif;
background-color: #f8f9fb;
color: #333d49;
line-height: 1.65;
}
/* Display headings use Jakarta Sans */
h1, h2, h3, h4, h5, h6 {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
line-height: 1.3;
letter-spacing: -0.01em;
}
/* Focus styles for accessibility */
:focus-visible {
outline: 2px solid #fe7400;
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: #fe7400;
color: #ffffff;
}
/* Smooth transitions for interactive elements */
button, a, input, select, textarea {
font-family: inherit;
}
}
html {
font-family: 'Lexend', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Typography scale */
.text-display {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
body {
font-family: 'Lexend', sans-serif;
background-color: #ffffff;
color: #333d49;
line-height: 1.6;
.text-heading {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 600;
letter-spacing: -0.01em;
}
.text-label {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-weight: 500;
font-size: 0.875rem;
letter-spacing: 0.01em;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #333d49;
border-radius: 4px;
background: #cdd2d9;
border-radius: 100px;
}
::-webkit-scrollbar-thumb:hover {
background: #113559;
background: #9aa1ac;
}
/* Focus styles for accessibility */
:focus-visible {
outline: 2px solid #fe7400;
outline-offset: 2px;
/* Premium card shadow system */
.shadow-card {
box-shadow:
0 1px 2px rgba(17, 53, 89, 0.04),
0 4px 12px rgba(17, 53, 89, 0.06);
}
/* Selection color */
::selection {
background-color: #fe7400;
color: #ffffff;
.shadow-card-hover {
box-shadow:
0 2px 4px rgba(17, 53, 89, 0.06),
0 8px 24px rgba(17, 53, 89, 0.1);
}
.shadow-card-elevated {
box-shadow:
0 4px 6px rgba(17, 53, 89, 0.04),
0 12px 32px rgba(17, 53, 89, 0.08);
}
/* Gradient utilities */
.bg-gradient-navy {
background: linear-gradient(135deg, #113559 0%, #1a4370 100%);
}
.bg-gradient-dark {
background: linear-gradient(135deg, #333d49 0%, #252d36 100%);
}
.bg-gradient-accent {
background: linear-gradient(135deg, #fe7400 0%, #e56800 100%);
}
/* Print styles */
@media print {
/* Reset page */
@page {
margin: 0.6in 0.75in;
size: letter;
}
html, body {
background: white !important;
color: #333d49 !important;
font-size: 11pt !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Hide non-content elements */
.print-hide,
[data-print-hide] {
display: none !important;
}
/* Show print-only elements */
.print-show {
display: block !important;
}
/* Remove decorative styling */
.shadow-card,
.shadow-card-hover,
.shadow-card-elevated {
box-shadow: none !important;
border: 1px solid #d1d5db !important;
}
/* Flatten card padding for print */
.bg-gradient-navy {
background: #113559 !important;
border-radius: 8px !important;
}
/* Ensure no page breaks mid-section */
.print-section {
break-inside: avoid;
}
/* Remove animations */
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
/* Clean link styling */
a {
text-decoration: none !important;
color: inherit !important;
}
}

View File

@@ -1,8 +1,17 @@
import axios from 'axios';
import type { QuoteData, QuoteResult } from '@/types/quote';
/**
* API client for MSP Quote Wizard
*
* Proxied via /msp-api/ -> backend /api/ on 172.16.3.30:8001
* Endpoints:
* - POST /quotes - Create quote draft
* - GET /quotes/{access_token} - Get quote
* - PUT /quotes/{access_token} - Update quote
* - POST /quotes/{access_token}/items - Add item
* - DELETE /quotes/{access_token}/items/{item_id} - Remove item
* - POST /quotes/{access_token}/submit - Submit quote
* - GET /quotes/{access_token}/pdf - Get PDF (501 placeholder)
*/
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
@@ -15,70 +24,179 @@ export const apiClient = axios.create({
timeout: 10000,
});
// Request interceptor for adding auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('quote_wizard_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('quote_wizard_token');
}
return Promise.reject(error);
}
);
// -- Response types matching backend schemas --
export interface QuoteCreatedResponse {
id: string;
access_token: string;
status: string;
message: string;
}
export interface QuoteItemResponse {
id: string;
quote_id: string;
service_name: string;
service_description: string | null;
category: string;
billing_frequency: string;
unit_price: string;
quantity: number;
setup_fee: string | null;
is_required: boolean;
sort_order: number;
line_total: string;
monthly_amount: string;
created_at: string;
updated_at: string;
}
export interface QuoteResponse {
id: string;
access_token: string;
status: string;
company_name: string | null;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
employee_count: number | null;
notes: string | null;
monthly_total: string;
setup_total: string;
annual_total: string;
expires_at: string | null;
submitted_at: string | null;
created_at: string;
updated_at: string;
items: QuoteItemResponse[];
}
// -- Request types matching backend schemas --
export interface QuoteCreateRequest {
employee_count?: number;
notes?: string;
items?: QuoteItemCreateRequest[];
}
export interface QuoteUpdateRequest {
company_name?: string;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
employee_count?: number;
notes?: string;
items?: QuoteItemCreateRequest[];
}
export interface QuoteItemCreateRequest {
category: string;
product_code: string;
product_name: string;
description?: string;
quantity: number;
unit_price: string;
setup_price?: string;
billing_frequency: string;
tier?: string;
is_recommended?: boolean;
}
export interface QuoteSubmitRequest {
company_name: string;
contact_name: string;
contact_email: string;
contact_phone?: string;
notes?: string;
}
// -- API functions --
/**
* API endpoints
* Create a new quote draft. Returns access token for future operations.
*/
export const quoteApi = {
/**
* Calculate quote based on provided data
*/
calculateQuote: async (data: QuoteData): Promise<QuoteResult> => {
const response = await apiClient.post<QuoteResult>('/api/quotes/calculate', data);
return response.data;
},
export async function createQuote(data: QuoteCreateRequest): Promise<QuoteCreatedResponse> {
const response = await apiClient.post<QuoteCreatedResponse>('/quotes', data);
return response.data;
}
/**
* Save quote for later retrieval
*/
saveQuote: async (data: QuoteData & { email: string }): Promise<{ quoteId: string }> => {
const response = await apiClient.post<{ quoteId: string }>('/api/quotes/save', data);
return response.data;
},
/**
* Get a quote by its access token.
*/
export async function getQuote(accessToken: string): Promise<QuoteResponse> {
const response = await apiClient.get<QuoteResponse>(`/quotes/${accessToken}`);
return response.data;
}
/**
* Retrieve saved quote by ID
*/
getQuote: async (quoteId: string): Promise<QuoteData & QuoteResult> => {
const response = await apiClient.get<QuoteData & QuoteResult>(`/api/quotes/${quoteId}`);
return response.data;
},
/**
* Update a draft quote (wizard progress saves).
*/
export async function updateQuote(
accessToken: string,
data: QuoteUpdateRequest,
): Promise<QuoteResponse> {
const response = await apiClient.put<QuoteResponse>(
`/quotes/${accessToken}`,
data,
);
return response.data;
}
/**
* Submit quote request for sales follow-up
*/
submitQuoteRequest: async (data: QuoteData & {
contactInfo: {
name: string;
email: string;
phone?: string;
}
}): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/api/quotes/submit', data);
return response.data;
},
};
/**
* Add a single item to a quote.
*/
export async function addQuoteItem(
accessToken: string,
item: QuoteItemCreateRequest,
): Promise<QuoteResponse> {
const response = await apiClient.post<QuoteResponse>(
`/quotes/${accessToken}/items`,
item,
);
return response.data;
}
/**
* Remove an item from a quote.
*/
export async function removeQuoteItem(
accessToken: string,
itemId: string,
): Promise<QuoteResponse> {
const response = await apiClient.delete<QuoteResponse>(
`/quotes/${accessToken}/items/${itemId}`,
);
return response.data;
}
/**
* Submit a finalized quote with contact information.
*/
export async function submitQuote(
accessToken: string,
data: QuoteSubmitRequest,
): Promise<QuoteResponse> {
const response = await apiClient.post<QuoteResponse>(
`/quotes/${accessToken}/submit`,
data,
);
return response.data;
}
/**
* Get quote PDF. Currently returns 501 Not Implemented.
*/
export async function getQuotePdf(accessToken: string): Promise<Blob> {
const response = await apiClient.get(`/quotes/${accessToken}/pdf`, {
responseType: 'blob',
});
return response.data;
}

View File

@@ -28,7 +28,7 @@ export interface GPSSelection {
// Support Plan Types
// ============================================================================
export type SupportPlanId = 'essential' | 'standard' | 'premium' | 'priority';
export type SupportPlanId = 'none' | 'essential' | 'standard' | 'premium' | 'priority';
export type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
export interface SupportPlan {
@@ -138,9 +138,11 @@ export interface EmailSelection {
}
// ============================================================================
// Company & Contact Types
// Client & Contact Types
// ============================================================================
export type ClientType = 'company' | 'individual';
export type Industry =
| 'Healthcare'
| 'Legal'
@@ -169,11 +171,25 @@ export interface ContactInfo {
agreedToTerms: boolean;
}
// ============================================================================
// Service Interest Selection (for discovery step)
// ============================================================================
export interface ServiceInterests {
gps: boolean;
support: boolean;
voip: boolean;
webHosting: boolean;
email: boolean;
}
// ============================================================================
// Quote Data & Result Types
// ============================================================================
export interface QuoteData {
clientType: ClientType;
serviceInterests: ServiceInterests;
company: CompanyInfo;
gps: GPSSelection;
support: SupportSelection;

View File

@@ -5,12 +5,17 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
base: '/quote/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
server: {
port: 5173,
host: true,

View File

@@ -0,0 +1,24 @@
RewriteEngine On
# Pass Authorization header through CGI/suPHP
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle CORS preflight requests
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ index.php [QSA,L]
# Route all requests to index.php unless the file or directory exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
# Deny access to PHP files other than index.php
<FilesMatch "^(?!index\.php$).+\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>

View File

@@ -0,0 +1,51 @@
<?php
/**
* Configuration for MSP Quote Wizard PHP API.
*
* All credentials and settings are defined here. On cPanel, this file
* should be outside the web root or protected via .htaccess.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
// --------------------------------------------------------------------------
// Database
// --------------------------------------------------------------------------
define('DB_HOST', 'localhost');
define('DB_NAME', 'azcomputerguru_acg2025');
define('DB_USER', 'azcomputerguru_acg2025');
define('DB_PASS', 'Kg-.v?{jFXSH');
define('DB_CHARSET', 'utf8mb4');
define('DB_TABLE_PREFIX', 'acgq_');
// --------------------------------------------------------------------------
// Microsoft Graph API (email sending)
// --------------------------------------------------------------------------
define('GRAPH_TENANT_ID', 'ce61461e-81a0-4c84-bb4a-7b354a9a356d');
define('GRAPH_CLIENT_ID', '15b0fafb-ab51-4cc9-adc7-f6334c805c22');
define('GRAPH_CLIENT_SECRET', 'rRN8Q~FPfSL8O24iZthi_LVJTjGOCZG.DnxGHaSk');
define('GRAPH_SENDER_EMAIL', 'noreply@azcomputerguru.com');
// --------------------------------------------------------------------------
// Admin / Auth
// --------------------------------------------------------------------------
define('ADMIN_NOTIFICATION_EMAIL', 'mike@azcomputerguru.com');
define('ADMIN_API_KEY', 'RqzhynUHgKxXaQTVFiM9TQyl8C3riuJu4Z_wwt6IGN0');
// --------------------------------------------------------------------------
// Application
// --------------------------------------------------------------------------
define('QUOTE_DRAFT_EXPIRY_DAYS', 30);
define('QUOTE_SUBMITTED_EXPIRY_DAYS', 90);
// CORS allowed origins (comma-separated or '*' for dev)
define('CORS_ALLOWED_ORIGINS', 'https://azcomputerguru.com,https://www.azcomputerguru.com');
// --------------------------------------------------------------------------
// Logging
// --------------------------------------------------------------------------
define('LOG_FILE', __DIR__ . '/../logs/api.log');

View File

@@ -0,0 +1,55 @@
<?php
/**
* PDO database connection singleton.
*
* Provides a lazy-loaded PDO instance configured for the quote wizard
* database with utf8mb4, exception error mode, and associative fetch.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/config.php';
/**
* Return a shared PDO connection instance.
*
* The connection is created on first call and reused for the lifetime
* of the request. Uses utf8mb4 charset, ERRMODE_EXCEPTION, and
* FETCH_ASSOC as the default fetch mode.
*
* @return PDO
* @throws RuntimeException If the connection cannot be established.
*/
function get_db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
DB_HOST,
DB_NAME,
DB_CHARSET
);
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8mb4'",
]);
} catch (PDOException $e) {
app_log('ERROR', 'Database connection failed: ' . $e->getMessage());
throw new RuntimeException('Database connection failed');
}
return $pdo;
}

View File

@@ -0,0 +1,277 @@
<?php
/**
* Shared utility functions for the MSP Quote Wizard API.
*
* Provides UUID generation, token generation, JSON response helpers,
* input validation, CORS headers, and logging.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/config.php';
// --------------------------------------------------------------------------
// UUID / Token generation
// --------------------------------------------------------------------------
/**
* Generate a UUID v4 string (lowercase, 36 chars with hyphens).
*
* Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* where y is one of 8, 9, a, b.
*
* @return string
*/
function generate_uuid(): string
{
$bytes = random_bytes(16);
// Set version to 4 (0100 in binary)
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
// Set variant to RFC 4122 (10xx in binary)
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
return sprintf(
'%s-%s-%s-%s-%s',
bin2hex(substr($bytes, 0, 4)),
bin2hex(substr($bytes, 4, 2)),
bin2hex(substr($bytes, 6, 2)),
bin2hex(substr($bytes, 8, 2)),
bin2hex(substr($bytes, 10, 6))
);
}
/**
* Generate a URL-safe access token matching Python's secrets.token_urlsafe(32).
*
* Produces a 43-character base64url-encoded string (no padding) from 32
* random bytes, exactly matching the Python implementation.
*
* @return string 43-character URL-safe token
*/
function generate_access_token(): string
{
$bytes = random_bytes(32);
// base64url encode: replace +/ with -_, strip padding =
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// --------------------------------------------------------------------------
// JSON response helpers
// --------------------------------------------------------------------------
/**
* Send a JSON response with the given data and HTTP status code.
*
* Sets Content-Type header, outputs JSON, and terminates the script.
*
* @param mixed $data Data to encode as JSON.
* @param int $status HTTP status code (default 200).
* @return never
*/
function json_response($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
exit;
}
/**
* Send a JSON error response.
*
* @param string $message Error message.
* @param int $status HTTP status code (default 400).
* @param mixed|null $details Additional error details.
* @return never
*/
function error_response(string $message, int $status = 400, $details = null): void
{
$body = ['detail' => $message];
if ($details !== null) {
$body['errors'] = $details;
}
json_response($body, $status);
}
// --------------------------------------------------------------------------
// Request parsing
// --------------------------------------------------------------------------
/**
* Parse the JSON request body.
*
* @return array Decoded JSON as an associative array.
*/
function get_json_body(): array
{
$raw = file_get_contents('php://input');
if (empty($raw)) {
return [];
}
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_response('Invalid JSON in request body', 400);
}
return $data;
}
/**
* Get the client IP address, accounting for reverse proxies.
*
* Checks X-Forwarded-For first, then X-Real-IP, then REMOTE_ADDR.
*
* @return string|null
*/
function get_client_ip(): ?string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return $_SERVER['REMOTE_ADDR'] ?? null;
}
/**
* Get the User-Agent header value.
*
* @return string|null
*/
function get_user_agent(): ?string
{
return $_SERVER['HTTP_USER_AGENT'] ?? null;
}
// --------------------------------------------------------------------------
// CORS
// --------------------------------------------------------------------------
/**
* Emit CORS headers based on the configured allowed origins.
*
* For preflight (OPTIONS) requests, this also sets the allowed methods
* and headers, then terminates the script with 204.
*/
function cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = array_map('trim', explode(',', CORS_ALLOWED_ORIGINS));
// Allow the origin if it matches our whitelist, or allow all if '*'
if (in_array('*', $allowed, true) || in_array($origin, $allowed, true)) {
$send_origin = in_array('*', $allowed, true) ? '*' : $origin;
header("Access-Control-Allow-Origin: {$send_origin}");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400');
// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
}
// --------------------------------------------------------------------------
// Validation
// --------------------------------------------------------------------------
/**
* Validate that all required fields are present and non-empty in the data.
*
* @param array $data Associative array of input data.
* @param string[] $fields List of required field names.
* @return string[] Array of error messages (empty if valid).
*/
function validate_required(array $data, array $fields): array
{
$errors = [];
foreach ($fields as $field) {
if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
$errors[] = "Field '{$field}' is required.";
}
}
return $errors;
}
/**
* Validate an email address.
*
* @param string $email Email address to validate.
* @return bool True if valid.
*/
function validate_email(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// --------------------------------------------------------------------------
// Logging
// --------------------------------------------------------------------------
/**
* Append a message to the application log file.
*
* @param string $level Log level (INFO, WARNING, ERROR).
* @param string $message Log message.
*/
function app_log(string $level, string $message): void
{
$dir = dirname(LOG_FILE);
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
}
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
@file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
}
// --------------------------------------------------------------------------
// Datetime helpers
// --------------------------------------------------------------------------
/**
* Format a datetime value for JSON output (ISO 8601 format).
*
* Accepts a datetime string from MySQL (Y-m-d H:i:s) and returns
* an ISO 8601 string, or null if input is null/empty.
*
* @param string|null $dt MySQL datetime string.
* @return string|null ISO 8601 formatted string.
*/
function format_datetime(?string $dt): ?string
{
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
return null;
}
// MySQL DATETIME is already in UTC for this application
$ts = strtotime($dt);
if ($ts === false) {
return null;
}
return gmdate('Y-m-d\TH:i:s\Z', $ts);
}
/**
* Get the current UTC datetime in MySQL format.
*
* @return string Y-m-d H:i:s
*/
function utc_now(): string
{
return gmdate('Y-m-d H:i:s');
}

View File

@@ -0,0 +1,164 @@
<?php
/**
* Front controller / router for the MSP Quote Wizard PHP API.
*
* All requests are routed here via .htaccess. Parses the URI and method,
* emits CORS headers, then dispatches to the appropriate route handler.
*
* Route map:
* POST /quotes -> create quote
* GET /quotes/{token} -> get quote by token
* PUT /quotes/{token} -> update quote
* POST /quotes/{token}/items -> add item
* DELETE /quotes/{token}/items/{id} -> remove item
* POST /quotes/{token}/submit -> submit quote
* GET /admin/quotes -> list quotes (auth)
* GET /admin/quotes/stats -> get stats (auth)
* GET /admin/quotes/{id} -> get quote by ID (auth)
* PUT /admin/quotes/{id} -> update quote status (auth)
* POST /admin/quotes/{id}/sync-syncro -> sync to Syncro (auth)
*/
// Error reporting: log only, never display to client
ini_set('display_errors', '0');
error_reporting(E_ALL);
require_once __DIR__ . '/helpers.php';
// Emit CORS headers on every request (handles OPTIONS preflight too)
cors_headers();
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
// Get the path relative to the API directory
// Strip the script directory from REQUEST_URI to get the route path
$request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Determine the base path (the directory where index.php lives)
$script_dir = dirname($_SERVER['SCRIPT_NAME']);
if ($script_dir !== '/' && $script_dir !== '\\') {
$path = substr($request_uri, strlen($script_dir));
} else {
$path = $request_uri;
}
// Normalize: ensure leading slash, remove trailing slash (except root)
$path = '/' . ltrim($path, '/');
if ($path !== '/' && substr($path, -1) === '/') {
$path = rtrim($path, '/');
}
// Split path into segments for matching
$segments = array_values(array_filter(explode('/', $path), function ($s) {
return $s !== '';
}));
$seg_count = count($segments);
// --------------------------------------------------------------------------
// Route dispatch
// --------------------------------------------------------------------------
// -- Public quote routes: /quotes/... --
if ($seg_count >= 1 && $segments[0] === 'quotes') {
require_once __DIR__ . '/routes/quotes.php';
// POST /quotes -> create
if ($seg_count === 1 && $method === 'POST') {
handle_create_quote();
}
// GET /quotes/{token} -> get
if ($seg_count === 2 && $method === 'GET') {
handle_get_quote($segments[1]);
}
// PUT /quotes/{token} -> update
if ($seg_count === 2 && $method === 'PUT') {
handle_update_quote($segments[1]);
}
// POST /quotes/{token}/items -> add item
if ($seg_count === 3 && $segments[2] === 'items' && $method === 'POST') {
handle_add_item($segments[1]);
}
// DELETE /quotes/{token}/items/{id} -> remove item
if ($seg_count === 4 && $segments[2] === 'items' && $method === 'DELETE') {
handle_remove_item($segments[1], $segments[3]);
}
// POST /quotes/{token}/submit -> submit
if ($seg_count === 3 && $segments[2] === 'submit' && $method === 'POST') {
handle_submit_quote($segments[1]);
}
// If we got here with a quotes path but no match, 404
error_response('Not found', 404);
}
// -- Admin routes: /admin/quotes/... --
if ($seg_count >= 2 && $segments[0] === 'admin' && $segments[1] === 'quotes') {
require_once __DIR__ . '/routes/admin.php';
// GET /admin/quotes -> list
if ($seg_count === 2 && $method === 'GET') {
handle_list_quotes();
}
// GET /admin/quotes/stats -> stats
if ($seg_count === 3 && $segments[2] === 'stats' && $method === 'GET') {
handle_get_stats();
}
// GET /admin/quotes/{id} -> get by ID
if ($seg_count === 3 && $segments[2] !== 'stats' && $method === 'GET') {
handle_admin_get_quote($segments[2]);
}
// PUT /admin/quotes/{id} -> admin update
if ($seg_count === 3 && $method === 'PUT') {
handle_admin_update_quote($segments[2]);
}
// POST /admin/quotes/{id}/sync-syncro -> syncro sync
if ($seg_count === 4 && $segments[3] === 'sync-syncro' && $method === 'POST') {
handle_sync_syncro($segments[2]);
}
// If we got here with an admin path but no match, 404
error_response('Not found', 404);
}
// --------------------------------------------------------------------------
// Health check: GET /health
// --------------------------------------------------------------------------
if ($seg_count === 1 && $segments[0] === 'health' && $method === 'GET') {
// Quick DB connectivity check
try {
require_once __DIR__ . '/db.php';
$db = get_db();
$db->query('SELECT 1');
json_response(['status' => 'ok', 'database' => 'connected']);
} catch (\Throwable $e) {
json_response(['status' => 'error', 'database' => 'disconnected'], 503);
}
}
// --------------------------------------------------------------------------
// Root: GET /
// --------------------------------------------------------------------------
if ($seg_count === 0 && $method === 'GET') {
json_response([
'service' => 'MSP Quote Wizard API',
'version' => '1.0.0',
'status' => 'running',
]);
}
// --------------------------------------------------------------------------
// 404 fallback
// --------------------------------------------------------------------------
error_response('Not found', 404);

View File

@@ -0,0 +1,148 @@
<?php
/**
* Admin route handlers for quote management.
*
* All handlers require a valid API key in the Authorization header.
* Format: Authorization: Bearer {ADMIN_API_KEY}
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../services/quote_service.php';
require_once __DIR__ . '/../services/syncro_service.php';
/**
* Verify the admin API key from the Authorization header.
*
* Expects: Authorization: Bearer {api_key}
* Terminates with 401 if missing or invalid.
*/
function check_admin_auth(): void
{
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
// Apache CGI/suPHP may strip Authorization header; check env var fallback
if (empty($header) && !empty(getenv('HTTP_AUTHORIZATION'))) {
$header = getenv('HTTP_AUTHORIZATION');
}
if (empty($header)) {
error_response('Authorization header required', 401);
}
// Extract bearer token
if (strpos($header, 'Bearer ') !== 0) {
error_response('Invalid authorization format. Expected: Bearer {api_key}', 401);
}
$token = substr($header, 7);
if (ADMIN_API_KEY === 'CHANGE_ME_PLACEHOLDER') {
app_log('WARNING', '[WARNING] Admin API key is not configured (still placeholder)');
error_response('Admin API key not configured on server', 500);
}
if (!hash_equals(ADMIN_API_KEY, $token)) {
app_log('WARNING', '[WARNING] Invalid admin API key attempt from ' . (get_client_ip() ?? 'unknown'));
error_response('Invalid API key', 401);
}
}
/**
* GET /admin/quotes
*
* List quotes with pagination and optional filters.
* Query params: skip, limit, status, search
*/
function handle_list_quotes(): void
{
check_admin_auth();
$db = get_db();
$skip = max(0, (int)($_GET['skip'] ?? 0));
$limit = min(1000, max(1, (int)($_GET['limit'] ?? 100)));
$status = $_GET['status'] ?? null;
$search = $_GET['search'] ?? null;
// Validate status if provided
if ($status !== null && $status !== '' && !in_array($status, VALID_STATUSES, true)) {
error_response("Invalid status filter: {$status}", 400);
}
$result = list_quotes($db, $skip, $limit, $status, $search);
json_response([
'total' => $result['total'],
'skip' => $skip,
'limit' => $limit,
'quotes' => $result['quotes'],
]);
}
/**
* GET /admin/quotes/stats
*
* Get dashboard statistics for quotes.
*/
function handle_get_stats(): void
{
check_admin_auth();
$db = get_db();
$stats = get_stats($db);
json_response($stats);
}
/**
* GET /admin/quotes/{id}
*
* Get a single quote by ID with items, activities, and notifications.
*/
function handle_admin_get_quote(string $quote_id): void
{
check_admin_auth();
$db = get_db();
$quote = get_quote_by_id($db, $quote_id);
$response = build_admin_quote_response($db, $quote);
json_response($response);
}
/**
* PUT /admin/quotes/{id}
*
* Update a quote's status and/or expiration (admin only).
*/
function handle_admin_update_quote(string $quote_id): void
{
check_admin_auth();
$data = get_json_body();
$db = get_db();
$quote = admin_update_quote($db, $quote_id, $data, 'admin');
$response = build_admin_quote_response($db, $quote);
json_response($response);
}
/**
* POST /admin/quotes/{id}/sync-syncro
*
* Trigger a SyncroRMM sync for a quote.
*/
function handle_sync_syncro(string $quote_id): void
{
check_admin_auth();
$db = get_db();
$quote = get_quote_by_id($db, $quote_id);
$result = sync_quote_to_syncro($db, $quote);
json_response($result);
}

View File

@@ -0,0 +1,183 @@
<?php
/**
* Public quote route handlers.
*
* These endpoints do not require authentication. They allow prospects
* to create, view, update, and submit quotes using an access token.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../services/quote_service.php';
require_once __DIR__ . '/../services/email_service.php';
/**
* POST /quotes
*
* Create a new quote draft. Returns the quote ID, access token, status, and
* a success message. HTTP 201 on success.
*/
function handle_create_quote(): void
{
$data = get_json_body();
$ip = get_client_ip();
$ua = get_user_agent();
$db = get_db();
// Validate employee_count if provided
if (isset($data['employee_count'])) {
$data['employee_count'] = (int)$data['employee_count'];
if ($data['employee_count'] < 1) {
error_response('employee_count must be >= 1', 422);
}
}
$quote = create_quote($db, $data, $ip, $ua);
json_response([
'id' => $quote['id'],
'access_token' => $quote['access_token'],
'status' => $quote['status'],
'message' => 'Quote created successfully. Use the access_token to access your quote.',
], 201);
}
/**
* GET /quotes/{token}
*
* Retrieve a quote by its access token. Returns the full quote with items.
*/
function handle_get_quote(string $token): void
{
$db = get_db();
$quote = get_quote_by_token($db, $token);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* PUT /quotes/{token}
*
* Update a draft quote's fields and/or replace all items.
*/
function handle_update_quote(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
$quote = update_quote($db, $token, $data, $ip);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* POST /quotes/{token}/items
*
* Add a single item to a draft quote. HTTP 201 on success.
*/
function handle_add_item(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
// Validate required item fields
$errors = validate_required($data, ['category', 'product_code', 'product_name', 'unit_price']);
if (!empty($errors)) {
error_response('Validation error', 422, $errors);
}
$quote = add_item($db, $token, $data, $ip);
$response = build_quote_response($db, $quote);
json_response($response, 201);
}
/**
* DELETE /quotes/{token}/items/{item_id}
*
* Remove an item from a draft quote.
*/
function handle_remove_item(string $token, string $item_id): void
{
$ip = get_client_ip();
$db = get_db();
$quote = remove_item($db, $token, $item_id, $ip);
$response = build_quote_response($db, $quote);
json_response($response);
}
/**
* POST /quotes/{token}/submit
*
* Submit a draft quote with contact information. Sends an email notification
* to the admin (best-effort -- email failure does not fail the submission).
*/
function handle_submit_quote(string $token): void
{
$data = get_json_body();
$ip = get_client_ip();
$db = get_db();
// Validate required submission fields
$errors = validate_required($data, ['company_name', 'contact_name', 'contact_email']);
if (!empty($errors)) {
error_response('Validation error', 422, $errors);
}
if (!validate_email($data['contact_email'])) {
error_response('Invalid email address', 422, ["Field 'contact_email' is not a valid email."]);
}
// Submit the quote (updates DB)
$quote = submit_quote($db, $token, $data, $ip);
// Send email notification (best-effort, do not fail the request)
try {
$items_raw = fetch_items_for_quote($db, $quote['id']);
$items_data = array_map(function ($item) {
return [
'service_name' => $item['product_name'],
'billing_frequency' => $item['billing_frequency'],
'unit_price' => $item['unit_price'],
'quantity' => (int)$item['quantity'],
];
}, $items_raw);
$html = build_quote_notification_html(
$data['company_name'],
$data['contact_name'],
$data['contact_email'],
$data['contact_phone'] ?? null,
number_format((float)$quote['monthly_total'], 2, '.', ''),
number_format((float)$quote['setup_total'], 2, '.', ''),
$items_data,
$data['notes'] ?? null
);
$subject = "New Quote Submission: {$data['company_name']} - \$" .
number_format((float)$quote['monthly_total'], 2, '.', '') . "/mo";
$sent = send_email(ADMIN_NOTIFICATION_EMAIL, $subject, $html);
// Update notification record with result
$notif_status = $sent ? 'sent' : 'failed';
$notif_error = $sent ? null : 'Graph API send failed';
update_notification_status($db, $quote['id'], $notif_status, $notif_error);
} catch (\Throwable $e) {
app_log('ERROR', '[ERROR] Failed to send quote notification email: ' . $e->getMessage());
// Do not fail the submission
}
// Return the full quote response
$response = build_quote_response($db, $quote);
json_response($response);
}

View File

@@ -0,0 +1,293 @@
<?php
/**
* Email service using Microsoft Graph API.
*
* Sends email via M365 Graph API using client credentials flow (OAuth 2.0).
* Used for quote submission notifications and other system emails.
*
* All HTTP calls use curl.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../helpers.php';
// Token cache: persists across calls within a single request
$_graph_token_cache = [
'access_token' => null,
'expires_at' => 0,
];
/**
* Obtain an access token from Azure AD using client credentials flow.
*
* Caches the token in a static variable and reuses it until 60 seconds
* before expiry.
*
* @return string Bearer access token.
* @throws RuntimeException If credentials are not configured or request fails.
*/
function get_graph_token(): string
{
global $_graph_token_cache;
// Return cached token if still valid (with 60s buffer)
if (
$_graph_token_cache['access_token'] !== null
&& $_graph_token_cache['expires_at'] > time() + 60
) {
return $_graph_token_cache['access_token'];
}
if (empty(GRAPH_TENANT_ID) || empty(GRAPH_CLIENT_ID) || GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER') {
throw new RuntimeException('Microsoft Graph API credentials not configured');
}
$token_url = "https://login.microsoftonline.com/" . GRAPH_TENANT_ID . "/oauth2/v2.0/token";
$post_fields = http_build_query([
'client_id' => GRAPH_CLIENT_ID,
'client_secret' => GRAPH_CLIENT_SECRET,
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $token_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $post_fields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
app_log('ERROR', "Graph token request failed (curl): {$curl_error}");
throw new RuntimeException("Failed to obtain Graph token: {$curl_error}");
}
if ($http_code !== 200) {
app_log('ERROR', "Graph token request failed (HTTP {$http_code}): {$response}");
throw new RuntimeException("Failed to obtain Graph token: HTTP {$http_code}");
}
$data = json_decode($response, true);
if (empty($data['access_token'])) {
app_log('ERROR', 'Graph token response missing access_token');
throw new RuntimeException('Invalid Graph token response');
}
$_graph_token_cache['access_token'] = $data['access_token'];
$_graph_token_cache['expires_at'] = time() + (int)($data['expires_in'] ?? 3600);
return $data['access_token'];
}
/**
* Send an email via Microsoft Graph API.
*
* @param string $to_email Recipient email address.
* @param string $subject Email subject.
* @param string $body_html HTML body content.
* @param string|null $cc_email Optional CC recipient.
* @return bool True if sent successfully, false otherwise.
*/
function send_email(string $to_email, string $subject, string $body_html, ?string $cc_email = null): bool
{
if (GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER' || empty(GRAPH_TENANT_ID)) {
app_log('WARNING', 'Graph API not configured - skipping email send');
return false;
}
try {
$token = get_graph_token();
} catch (RuntimeException $e) {
app_log('ERROR', 'Cannot send email - token error: ' . $e->getMessage());
return false;
}
$message = [
'message' => [
'subject' => $subject,
'body' => [
'contentType' => 'HTML',
'content' => $body_html,
],
'toRecipients' => [
['emailAddress' => ['address' => $to_email]],
],
],
'saveToSentItems' => 'true',
];
if ($cc_email !== null) {
$message['message']['ccRecipients'] = [
['emailAddress' => ['address' => $cc_email]],
];
}
$url = "https://graph.microsoft.com/v1.0/users/" . GRAPH_SENDER_EMAIL . "/sendMail";
$json_body = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json_body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
app_log('ERROR', "Graph sendMail curl error: {$curl_error}");
return false;
}
// Graph sendMail returns 202 on success (no body)
if ($http_code >= 200 && $http_code < 300) {
app_log('INFO', "[OK] Email sent to {$to_email}: {$subject}");
return true;
}
app_log('ERROR', "[ERROR] Graph sendMail failed (HTTP {$http_code}): {$response}");
return false;
}
/**
* Build the HTML email body for a quote submission notification.
*
* Matches the exact template from the Python email_service.py implementation.
*
* @param string $company_name Company name.
* @param string $contact_name Contact name.
* @param string $contact_email Contact email address.
* @param string|null $contact_phone Contact phone number.
* @param string $monthly_total Formatted monthly total.
* @param string $setup_total Formatted setup total.
* @param array $items Array of item data (service_name, billing_frequency, unit_price, quantity).
* @param string|null $notes Additional notes from the prospect.
* @return string HTML email body.
*/
function build_quote_notification_html(
string $company_name,
string $contact_name,
string $contact_email,
?string $contact_phone,
string $monthly_total,
string $setup_total,
array $items,
?string $notes = null
): string {
$items_html = '';
foreach ($items as $item) {
$freq = $item['billing_frequency'] ?? 'monthly';
$freq_label = $freq === 'monthly' ? '/mo' : ' (one-time)';
$qty = (int)($item['quantity'] ?? 1);
$price = $item['unit_price'] ?? '0.00';
$line_total = (float)$price * $qty;
$service_name = htmlspecialchars($item['service_name'] ?? '', ENT_QUOTES, 'UTF-8');
$price_formatted = htmlspecialchars($price, ENT_QUOTES, 'UTF-8');
$line_formatted = number_format($line_total, 2, '.', ',');
$items_html .= "
<tr>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb;\">{$service_name}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;\">{$qty}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$price_formatted}{$freq_label}</td>
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$line_formatted}{$freq_label}</td>
</tr>";
}
$notes_section = '';
if ($notes !== null && $notes !== '') {
$notes_escaped = htmlspecialchars($notes, ENT_QUOTES, 'UTF-8');
$notes_section = "
<div style=\"margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;\">
<strong style=\"color: #333d49;\">Notes:</strong>
<p style=\"margin: 4px 0 0; color: #555;\">{$notes_escaped}</p>
</div>";
}
$phone_line = $contact_phone ? '<br>Phone: ' . htmlspecialchars($contact_phone, ENT_QUOTES, 'UTF-8') : '';
$contact_name_escaped = htmlspecialchars($contact_name, ENT_QUOTES, 'UTF-8');
$company_escaped = htmlspecialchars($company_name, ENT_QUOTES, 'UTF-8');
$email_escaped = htmlspecialchars($contact_email, ENT_QUOTES, 'UTF-8');
$setup_section = '';
if ((float)($setup_total ?? 0) > 0) {
$setup_section = "<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>\${$setup_total}</strong></span></div>";
}
return "
<div style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;\">
<div style=\"background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;\">
<h1 style=\"color: white; margin: 0; font-size: 22px;\">New Quote Submission</h1>
<p style=\"color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;\">Arizona Computer Guru - MSP Quote Wizard</p>
</div>
<div style=\"padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;\">
<div style=\"margin-bottom: 20px;\">
<h2 style=\"color: #333d49; font-size: 18px; margin: 0 0 8px;\">Contact Information</h2>
<p style=\"margin: 0; color: #555; line-height: 1.6;\">
<strong>{$contact_name_escaped}</strong><br>
{$company_escaped}<br>
Email: <a href=\"mailto:{$email_escaped}\">{$email_escaped}</a>
{$phone_line}
</p>
</div>
<div style=\"background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;\">
<span style=\"color: rgba(255,255,255,0.8); font-size: 14px;\">Monthly Total</span>
<span style=\"color: white; font-size: 24px; font-weight: bold;\">\${$monthly_total}/mo</span>
</div>
{$setup_section}
<h3 style=\"color: #333d49; font-size: 16px; margin: 20px 0 8px;\">Services</h3>
<table style=\"width: 100%; border-collapse: collapse; font-size: 14px;\">
<thead>
<tr style=\"background: #f8f9fb;\">
<th style=\"padding: 8px 12px; text-align: left; color: #333d49;\">Service</th>
<th style=\"padding: 8px 12px; text-align: center; color: #333d49;\">Qty</th>
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Unit Price</th>
<th style=\"padding: 8px 12px; text-align: right; color: #333d49;\">Total</th>
</tr>
</thead>
<tbody>
{$items_html}
</tbody>
</table>
{$notes_section}
<div style=\"margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;\">
<p style=\"color: #999; font-size: 12px; margin: 0;\">
Submitted via <a href=\"https://azcomputerguru.com/quote\" style=\"color: #fe7400;\">azcomputerguru.com/quote</a>
</p>
</div>
</div>
</div>
";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
<?php
/**
* Syncro RMM integration service (stub).
*
* This is a placeholder for the SyncroRMM lead creation and customer
* lookup functionality. The full implementation will be added when
* Syncro API credentials and endpoint details are finalized.
*/
// Deny direct access
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
http_response_code(403);
exit('Direct access denied.');
}
require_once __DIR__ . '/../helpers.php';
/**
* Sync a quote to SyncroRMM as a lead.
*
* Checks for an existing customer by email/business name, then creates
* a lead in Syncro with the quote details.
*
* @param PDO $db Database connection.
* @param array $quote Quote row from database.
* @return array Result with keys: synced, is_existing_customer, syncro_lead_id, error
*/
function sync_quote_to_syncro(PDO $db, array $quote): array
{
$result = [
'synced' => false,
'is_existing_customer' => false,
'syncro_lead_id' => null,
'error' => 'Syncro integration not yet configured',
];
if (empty($quote['contact_email'])) {
$result['error'] = 'Quote has no contact email';
return $result;
}
app_log('INFO', "Syncro sync requested for quote {$quote['id']} - integration not yet configured");
return $result;
}

View File

@@ -0,0 +1,130 @@
-- ==========================================================================
-- MSP Quote Wizard - Database Schema
-- Target: MySQL 5.7+ / MariaDB 10.3+ on cPanel
-- Database: azcomputerguru_acg2025
-- Table prefix: acgq_
-- ==========================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- --------------------------------------------------------------------------
-- Quotes table - main quote records
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quotes` (
`id` CHAR(36) NOT NULL,
`access_token` VARCHAR(64) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`company_name` VARCHAR(255) DEFAULT NULL,
`contact_name` VARCHAR(255) DEFAULT NULL,
`contact_email` VARCHAR(255) DEFAULT NULL,
`contact_phone` VARCHAR(50) DEFAULT NULL,
`employee_count` INT DEFAULT NULL,
`industry` VARCHAR(100) DEFAULT NULL,
`current_it_situation` TEXT DEFAULT NULL,
`monthly_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`setup_total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`expires_at` DATETIME DEFAULT NULL,
`submitted_at` DATETIME DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` TEXT DEFAULT NULL,
`source` VARCHAR(50) DEFAULT 'website',
`utm_source` VARCHAR(100) DEFAULT NULL,
`utm_medium` VARCHAR(100) DEFAULT NULL,
`utm_campaign` VARCHAR(100) DEFAULT NULL,
`syncro_lead_id` VARCHAR(100) DEFAULT NULL,
`syncro_synced_at` DATETIME DEFAULT NULL,
`is_existing_customer` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_quotes_access_token` (`access_token`),
INDEX `idx_quotes_access_token` (`access_token`),
INDEX `idx_quotes_status` (`status`),
INDEX `idx_quotes_contact_email` (`contact_email`),
INDEX `idx_quotes_created_at` (`created_at`),
CONSTRAINT `ck_quotes_status` CHECK (
`status` IN ('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', 'archived')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote items table - line items within a quote
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_items` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`category` VARCHAR(50) NOT NULL,
`product_code` VARCHAR(50) NOT NULL,
`product_name` VARCHAR(255) NOT NULL,
`description` TEXT DEFAULT NULL,
`quantity` INT NOT NULL DEFAULT 1,
`unit_price` DECIMAL(10,2) NOT NULL,
`setup_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`billing_frequency` VARCHAR(20) NOT NULL DEFAULT 'monthly',
`tier` VARCHAR(50) DEFAULT NULL,
`is_recommended` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_items_quote_id` (`quote_id`),
INDEX `idx_quote_items_category` (`category`),
CONSTRAINT `fk_quote_items_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE,
CONSTRAINT `ck_quote_items_category` CHECK (
`category` IN ('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon', 'backup', 'security', 'other')
),
CONSTRAINT `ck_quote_items_billing_frequency` CHECK (
`billing_frequency` IN ('monthly', 'yearly', 'one_time')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote activity table - audit log of all actions on a quote
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_activity` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`action` VARCHAR(50) NOT NULL,
`step_name` VARCHAR(50) DEFAULT NULL,
`details` TEXT DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_activity_quote_id` (`quote_id`),
INDEX `idx_quote_activity_action` (`action`),
INDEX `idx_quote_activity_created_at` (`created_at`),
CONSTRAINT `fk_quote_activity_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Quote notifications table - tracks emails and webhooks sent
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `acgq_quote_notifications` (
`id` CHAR(36) NOT NULL,
`quote_id` CHAR(36) NOT NULL,
`notification_type` VARCHAR(30) NOT NULL,
`recipient` VARCHAR(255) NOT NULL,
`subject` VARCHAR(255) DEFAULT NULL,
`body` TEXT DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`attempts` INT NOT NULL DEFAULT 0,
`last_attempt_at` DATETIME DEFAULT NULL,
`sent_at` DATETIME DEFAULT NULL,
`error_message` TEXT DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_quote_notifications_quote_id` (`quote_id`),
INDEX `idx_quote_notifications_type` (`notification_type`),
INDEX `idx_quote_notifications_status` (`status`),
CONSTRAINT `fk_quote_notifications_quote` FOREIGN KEY (`quote_id`)
REFERENCES `acgq_quotes` (`id`) ON DELETE CASCADE,
CONSTRAINT `ck_quote_notifications_type` CHECK (
`notification_type` IN ('email', 'webhook')
),
CONSTRAINT `ck_quote_notifications_status` CHECK (
`status` IN ('pending', 'sent', 'failed')
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,209 @@
# MSP Quote Wizard Session Log - 2026-03-09
## Session Summary
Major deployment session for the MSP Quote Wizard. Started from code pulled from MacBook Air (commit a1a19f8), reviewed the full project, fixed 15+ backend model/schema mismatches, deployed frontend to azcomputerguru.com/quote on IX cPanel, debugged and fixed PHP reverse proxy, and applied comprehensive responsive design fixes to all wizard components.
### Key Accomplishments
1. Full backend model alignment with MariaDB schema (12+ field/table/enum fixes)
2. Frontend deployed to production at https://azcomputerguru.com/quote/
3. PHP reverse proxy debugged and fixed (CURLOPT_FOLLOWLOCATION for FastAPI 307 redirects)
4. Comprehensive responsive design fixes across all 9 wizard components
5. End-to-end API flow verified: create -> get -> add item -> submit
### Key Decisions
- Used PHP curl reverse proxy instead of direct API exposure (API on 172.16.3.30:8001, frontend on IX 172.16.3.10)
- Made contact_name/contact_email nullable in DB to support draft quotes
- Wrapped QuoteActivity details in JSON for MariaDB json_valid() CHECK constraint
- Used `CURLOPT_FOLLOWLOCATION` to handle FastAPI trailing-slash 307 redirects
- SSH to IX requires `-o IdentitiesOnly=yes -i ~/.ssh/id_ed25519` as root (too many keys causes auth failure)
---
## Infrastructure
### Servers
- **API Server:** 172.16.3.30:8001 (FastAPI/Uvicorn, production ClaudeTools API)
- **IX Server (Hosting):** 172.16.3.10 (cPanel/WHM, Apache, PHP 8.1.33)
- SSH: `ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@172.16.3.10`
- Root password: Gptf*77ttb!@#!@#
- Site path: /home/azcomputerguru/public_html/quote/
- cPanel account: azcomputerguru
- **Database:** 172.16.3.30:3306 / MariaDB 10.6.22
- DB: claudetools
- User: claudetools
- Password: CT_e8fcd5a3952030a79ed6debae6c954ed
### Deployment Architecture
```
Browser -> Cloudflare -> IX (172.16.3.10:443)
-> /quote/ -> index.html (SPA)
-> /quote/api/* -> .htaccess rewrite -> api-proxy.php -> curl -> 172.16.3.30:8001/api/*
```
### Files on IX (/home/azcomputerguru/public_html/quote/)
- index.html - SPA entry point
- assets/ - JS/CSS bundles
- api-proxy.php - PHP reverse proxy to API
- .htaccess - Rewrite rules (API proxy + SPA routing)
---
## Backend Fixes Applied
### Model Alignment (api/models/quote.py)
- Status enum: draft/submitted/viewed/followed_up/converted/expired (was reviewing/approved/rejected)
- ServiceCategory enum: gps_monitoring/support_plan/voip/web_hosting/email/hardware/addon
- BillingFrequency enum: monthly/yearly/one_time (was quarterly/annual)
- NotificationType enum: email/webhook (was email_sent/sms_sent/admin_alert/reminder_sent)
- Removed columns: notes, admin_notes, annual_total (don't exist in DB)
- Fixed reserved word: metadata -> details (SQLAlchemy reserves metadata)
- Fixed table name: quote_activities -> quote_activity
- Removed TimestampMixin from QuoteItem/QuoteActivity/QuoteNotification (no updated_at)
- Made contact_name/contact_email Optional for draft support
- QuoteItem fields: service_name->product_name, setup_fee->setup_price, is_required->is_recommended, added product_code/tier, removed sort_order
### Database ALTERs Applied
```sql
ALTER TABLE quotes MODIFY contact_name VARCHAR(255) NULL;
ALTER TABLE quotes MODIFY contact_email VARCHAR(255) NULL;
```
### Service Layer (api/services/quote_service.py)
- calculate_totals() returns (monthly, setup) tuple (removed annual)
- log_activity() wraps details in json.dumps({"message": details}) for json_valid() constraint
- Removed all references to notes/admin_notes/annual_total
- Syncro API key moved to env var SYNCRO_API_KEY
- Admin email from env var ADMIN_NOTIFICATION_EMAIL
### API Routers
- api/routers/quotes.py - 6 public endpoints (create, get, update, add item, remove item, submit)
- api/routers/admin_quotes.py - 5 admin endpoints (list, stats, detail, update status, sync-syncro)
- Both registered in api/main.py
### Dependencies Installed on Production
```bash
pip install email-validator httpx
```
---
## Frontend Changes
### Vite Config
- base: '/quote/' for subdirectory deployment
- build.outDir and sourcemap: false
### API Client (src/lib/api.ts)
- Complete rewrite to match actual backend endpoints
- Exports: createQuote, getQuote, updateQuote, addQuoteItem, removeQuoteItem, submitQuote, getQuotePdf
### Responsive Design Fixes (Applied 2026-03-09)
All wizard components updated for mobile-first responsive design:
**WizardContainer.tsx:**
- Running totals bar: responsive padding (p-2.5 sm:p-4), text sizes (text-lg sm:text-2xl)
- Step header: responsive padding (px-4 sm:px-6 md:px-8), icon sizes, truncation
- Content area: responsive padding
**Step1CompanyProfile.tsx:**
- Endpoint count input: flex-col on mobile, w-full sm:w-32
**Step2GPSMonitoring.tsx:**
- Tier grid: grid-cols-1 sm:grid-cols-2 md:grid-cols-3
- Equipment section: flex-shrink-0 on toggle, min-w-0 on text, responsive text sizes
- Monthly total: responsive text (text-2xl sm:text-3xl), whitespace-nowrap
**Step3SupportPlan.tsx:**
- Plan grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-4
- Block time grid: grid-cols-1 sm:grid-cols-3
- Toggle headers: flex-shrink-0, min-w-0, responsive text sizes
- Monthly total: responsive sizing
**Step4VoIP.tsx:**
- Toggle header: responsive icon/text sizes, flex-shrink-0
- User count: flex-col sm:flex-row, w-full sm:w-24
- Tier grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-4
- Hardware items: completely restructured - stacked layout with flex-wrap controls
- Monthly total: responsive sizing
**Step5WebEmail.tsx:**
- All tier grids: sm:grid-cols-2 md:grid-cols-3 (was md:grid-cols-3 only)
- Toggle headers: responsive icon/text/padding, flex-shrink-0
- Mailbox count: flex-col sm:flex-row
- Monthly total: responsive sizing
**Step6Summary.tsx:**
- Grand total: flex-col sm:flex-row for monthly investment header
- Text: text-3xl sm:text-4xl
- SummarySection header: responsive padding, truncation, flex-shrink-0
**Step7Contact.tsx:**
- Quote preview: flex-col sm:flex-row, responsive text
- Contact preferences: flex-wrap
- Trust indicators: flex-col sm:flex-row (was grid-cols-1 md:grid-cols-3)
---
## PHP Reverse Proxy (api-proxy.php)
### Key Fix: CURLOPT_FOLLOWLOCATION
FastAPI returns 307 redirects for trailing-slash URLs. PHP curl doesn't follow redirects by default, causing empty response bodies. Fixed by adding:
```php
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
```
### Important: Host Header Required
When testing from internal network, must use `Host: azcomputerguru.com` header. Direct IP access (172.16.3.10) hits wrong Apache vhost and PHP doesn't execute. Browser access works fine since it sends correct Host header.
```bash
# WORKS:
curl -s -H "Host: azcomputerguru.com" "http://172.16.3.10/quote/api/quotes" -X POST -H "Content-Type: application/json" -d '{"employee_count":5}'
# FAILS (wrong vhost):
curl -s "http://172.16.3.10/quote/api/quotes" -X POST ...
```
---
## Pending/Next Steps
1. **Frontend polish:** Run through wizard in browser to visually verify responsive fixes
2. **Admin dashboard:** No admin UI yet for viewing submitted quotes (admin API endpoints exist)
3. **Email notifications:** ADMIN_NOTIFICATION_EMAIL env var needs to be set on production
4. **Syncro integration:** SYNCRO_API_KEY env var needs to be set for lead sync
5. **Remove debug endpoint:** Already done (removed _debug path from api-proxy.php)
6. **SSL/CORS:** Currently CORS is wide open (Access-Control-Allow-Origin: *) - consider restricting
7. **Quote PDF generation:** Endpoint exists but likely needs implementation
8. **Production env vars to set:**
- ADMIN_NOTIFICATION_EMAIL
- SYNCRO_API_KEY
- SYNCRO_API_BASE_URL (defaults to computerguru.syncromsp.com)
---
## Commands Reference
### Deploy frontend to IX
```bash
cd D:/ClaudeTools/projects/msp-tools/quote-wizard/frontend
npm run build
scp -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 -r dist/index.html dist/assets/ root@172.16.3.10:/home/azcomputerguru/public_html/quote/
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 root@172.16.3.10 'chown -R azcomputerguru:azcomputerguru /home/azcomputerguru/public_html/quote/'
```
### Deploy api-proxy.php
```bash
scp -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 dist/api-proxy.php root@172.16.3.10:/home/azcomputerguru/public_html/quote/api-proxy.php
```
### Test API through proxy
```bash
curl -s -H "Host: azcomputerguru.com" -X POST -H "Content-Type: application/json" -d '{"employee_count":5}' "http://172.16.3.10/quote/api/quotes"
```
### Test API directly
```bash
curl -s -X POST -H "Content-Type: application/json" -d '{"employee_count":5}' "http://172.16.3.30:8001/api/quotes/"
```