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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -22,32 +22,33 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
ref
) => {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-40 disabled:cursor-not-allowed';
const variants = {
primary:
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
'bg-gradient-accent text-white hover:brightness-110 focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md active:brightness-95',
secondary:
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
outline:
'border-2 border-[#333d49] text-[#333d49] hover:bg-[#333d49] hover:text-white focus-visible:ring-[#333d49]',
'border-2 border-gray-200 text-[#333d49] hover:border-[#333d49] hover:bg-gray-50 focus-visible:ring-[#333d49]',
ghost:
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
'text-[#333d49] hover:bg-gray-100/80 focus-visible:ring-[#333d49]',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-base',
lg: 'px-7 py-3.5 text-lg',
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-2.5 text-sm',
lg: 'px-8 py-3.5 text-base',
};
return (
<motion.button
ref={ref}
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
whileHover={{ scale: disabled || isLoading ? 1 : 1.015 }}
whileTap={{ scale: disabled || isLoading ? 1 : 0.985 }}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
{...props}
>
{isLoading ? (
@@ -72,7 +73,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
Processing...
</>
) : (
children

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } from 'lucide-react';
import { User, Mail, Phone, MessageSquare, Shield, Clock, Sparkles } from 'lucide-react';
import { Input, Button } from '@/components/ui';
import { contactPreferences } from '@/lib/pricing-data';
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
@@ -36,7 +36,6 @@ export function Step7Contact({
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// Pre-fill company name if available
if (companyNameFromStep1 && !contactInfo.companyName) {
onUpdateContact({ companyName: companyNameFromStep1 });
}
@@ -77,7 +76,6 @@ export function Step7Contact({
if (validateForm()) {
onSubmit();
} else {
// Mark all fields as touched to show errors
setTouched({
name: true,
email: true,
@@ -95,29 +93,39 @@ export function Step7Contact({
>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Get Your Quote</h2>
<p className="text-gray-500">
<h2 className="text-2xl font-bold text-[#333d49] mb-2"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Get Your Quote
</h2>
<p className="text-gray-400">
We will send your customized quote and contact you to discuss next steps.
</p>
</div>
{/* Quote Preview */}
{quoteResult && (
<div className="bg-[#fe7400]/10 border border-[#fe7400]/30 rounded-lg p-4 mb-6 flex items-center justify-between">
<span className="text-[#333d49] font-medium">Your Estimated Monthly Total:</span>
<span className="text-2xl font-bold text-[#fe7400]">
{formatCurrency(quoteResult.monthlyTotal)}/mo
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-navy rounded-xl p-4 sm:p-5 mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
>
<span className="text-sm sm:text-base text-white/80 font-medium">Your Estimated Monthly Total</span>
<span className="text-xl sm:text-2xl font-bold text-white"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(quoteResult.monthlyTotal)}
<span className="text-xs sm:text-sm font-medium text-white/50 ml-1">/mo</span>
</span>
</div>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Contact Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<User className="w-4 h-4 text-[#fe7400]" />
Contact Name
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="text"
@@ -131,10 +139,11 @@ export function Step7Contact({
{/* Email */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Mail className="w-4 h-4 text-[#fe7400]" />
Email Address
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs">*</span>
</label>
<Input
type="email"
@@ -148,10 +157,11 @@ export function Step7Contact({
{/* Phone */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<Phone className="w-4 h-4 text-[#fe7400]" />
Phone Number
<span className="text-gray-400 font-normal">(recommended)</span>
<span className="text-gray-300 font-normal text-xs">(recommended)</span>
</label>
<Input
type="tel"
@@ -161,46 +171,34 @@ export function Step7Contact({
/>
</div>
{/* Company Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<Building2 className="w-4 h-4 text-[#fe7400]" />
Company Name
</label>
<Input
type="text"
value={contactInfo.companyName}
onChange={(e) => onUpdateContact({ companyName: e.target.value })}
placeholder="Your company name"
/>
</div>
{/* Current IT Situation */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
Current IT Situation
<span className="text-gray-400 font-normal">(optional)</span>
<span className="text-gray-300 font-normal text-xs">(optional)</span>
</label>
<textarea
value={contactInfo.currentITSituation}
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
placeholder="Tell us about your current IT setup and any challenges you're facing..."
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
className="w-full px-4 py-3 rounded-xl border border-gray-200 bg-white text-[#333d49] placeholder-gray-400 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/15 focus:border-[#fe7400] transition-all duration-200 resize-none"
/>
</div>
{/* Contact Preference */}
<div className="space-y-3">
<label className="text-sm font-medium text-[#333d49]">
<label className="text-sm font-medium text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Preferred Contact Method
</label>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4 sm:gap-5">
{contactPreferences.map((pref) => (
<label
key={pref.id}
className="flex items-center gap-2 cursor-pointer"
className="flex items-center gap-2.5 cursor-pointer group"
>
<input
type="radio"
@@ -210,7 +208,9 @@ export function Step7Contact({
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
className="w-4 h-4 text-[#fe7400] border-gray-300 focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-700">{pref.label}</span>
<span className="text-sm text-gray-500 group-hover:text-gray-700 transition-colors">
{pref.label}
</span>
</label>
))}
</div>
@@ -218,7 +218,7 @@ export function Step7Contact({
{/* Terms Checkbox */}
<div className="space-y-2 pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={contactInfo.agreedToTerms}
@@ -228,18 +228,18 @@ export function Step7Contact({
}}
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
/>
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-500 leading-relaxed">
I agree to receive communications about my quote and understand that I can
unsubscribe at any time. I have read and agree to the{' '}
<a href="/privacy" className="text-[#fe7400] hover:underline">
<a href="/privacy" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Privacy Policy
</a>{' '}
and{' '}
<a href="/terms" className="text-[#fe7400] hover:underline">
<a href="/terms" className="text-[#fe7400] hover:text-[#e56800] font-medium transition-colors">
Terms of Service
</a>
.
<span className="text-red-500">*</span>
<span className="text-red-500 text-xs ml-0.5">*</span>
</span>
</label>
{touched.agreedToTerms && errors.agreedToTerms && (
@@ -258,11 +258,16 @@ export function Step7Contact({
type="submit"
variant="primary"
size="lg"
className="w-full text-lg py-4"
className="w-full text-base py-4"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
{isSubmitting ? 'Submitting...' : (
<>
<Sparkles className="w-5 h-5 mr-2" />
Submit Quote Request
</>
)}
</Button>
</motion.div>
</form>
@@ -272,20 +277,26 @@ export function Step7Contact({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-8 pt-6 border-t border-gray-200"
className="mt-10 pt-6 border-t border-gray-100"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">No obligation quote</span>
<div className="flex flex-col sm:flex-row sm:justify-between gap-4 sm:gap-5">
<div className="flex items-center gap-3 justify-center sm:justify-start">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">No obligation quote</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Response within 24 hours</span>
<div className="flex items-center gap-3 justify-center">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Clock className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Response within 24 hours</span>
</div>
<div className="flex flex-col items-center gap-2">
<CheckCircle className="w-6 h-6 text-green-500" />
<span className="text-sm text-gray-600">Your data is secure</span>
<div className="flex items-center gap-3 justify-center sm:justify-end">
<div className="w-8 h-8 rounded-lg bg-[#ecfdf5] flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-[#059669]" />
</div>
<span className="text-sm text-gray-500">Your data is secure</span>
</div>
</div>
</motion.div>

View File

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

View File

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

View File

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