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:
69
projects/msp-tools/quote-wizard/fix_api.py
Normal file
69
projects/msp-tools/quote-wizard/fix_api.py
Normal 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}')
|
||||
1
projects/msp-tools/quote-wizard/frontend/.env.production
Normal file
1
projects/msp-tools/quote-wizard/frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=/msp-api
|
||||
@@ -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>
|
||||
|
||||
@@ -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>© {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>© {new Date().getFullYear()} AZ Computer Guru</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — 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 — 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 — 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>
|
||||
|
||||
@@ -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 — 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 — 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>
|
||||
);
|
||||
|
||||
@@ -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 — 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>
|
||||
|
||||
@@ -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 — SSL certificates, backups, security
|
||||
updates, DNS, and spam filtering — 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>
|
||||
)}
|
||||
|
||||
@@ -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 · azcomputerguru.com · (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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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’d like to explore. We’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">·</span>
|
||||
)}
|
||||
{selectedCount > 0 && (
|
||||
<span>Click Continue to configure each one</span>
|
||||
)}
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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’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’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’m looking for IT services for…
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal file
24
projects/msp-tools/quote-wizard/php-api/api/.htaccess
Normal 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>
|
||||
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal file
51
projects/msp-tools/quote-wizard/php-api/api/config.php
Normal 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');
|
||||
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal file
55
projects/msp-tools/quote-wizard/php-api/api/db.php
Normal 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;
|
||||
}
|
||||
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal 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');
|
||||
}
|
||||
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal file
164
projects/msp-tools/quote-wizard/php-api/api/index.php
Normal 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);
|
||||
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal file
148
projects/msp-tools/quote-wizard/php-api/api/routes/admin.php
Normal 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);
|
||||
}
|
||||
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal file
183
projects/msp-tools/quote-wizard/php-api/api/routes/quotes.php
Normal 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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
130
projects/msp-tools/quote-wizard/php-api/schema.sql
Normal file
130
projects/msp-tools/quote-wizard/php-api/schema.sql
Normal 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;
|
||||
@@ -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/"
|
||||
```
|
||||
Reference in New Issue
Block a user