Files
claudetools/projects/msp-tools/quote-wizard/frontend/src/components/wizard/steps/Step4VoIP.tsx
Mike Swanson af72a12e3e 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>
2026-03-10 19:59:08 -07:00

421 lines
20 KiB
TypeScript

import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, Phone, Headphones, Plus, Minus, X } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
import { voipTiers, voipHardware } from '@/lib/pricing-data';
import { formatCurrency } from '@/lib/utils';
import type { VoIPSelection, VoIPTierId, HardwareSelection } from '@/types/quote';
export interface Step4VoIPProps {
voipSelection: VoIPSelection;
onSetVoIPEnabled: (enabled: boolean) => void;
onSetVoIPTier: (tierId: VoIPTierId) => void;
onSetVoIPUserCount: (count: number) => void;
onAddHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
onRemoveHardware: (hardwareId: string) => void;
onUpdateHardwareQuantity: (hardwareId: string, quantity: number) => void;
getVoIPMonthly: () => number;
getVoIPOneTime: () => number;
}
export function Step4VoIP({
voipSelection,
onSetVoIPEnabled,
onSetVoIPTier,
onSetVoIPUserCount,
onAddHardware,
onRemoveHardware,
onUpdateHardwareQuantity,
getVoIPMonthly,
getVoIPOneTime,
}: Step4VoIPProps) {
const [showHardware, setShowHardware] = useState(false);
const getHardwareSelection = (hardwareId: string): HardwareSelection | undefined => {
return voipSelection.hardware.find((h) => h.hardwareId === hardwareId);
};
const handleHardwareToggle = (hardwareId: string, isRental: boolean) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
onRemoveHardware(hardwareId);
} else {
onAddHardware(hardwareId, 1, isRental);
}
};
const handleQuantityChange = (hardwareId: string, delta: number) => {
const existing = getHardwareSelection(hardwareId);
if (existing) {
const newQuantity = Math.max(1, existing.quantity + delta);
onUpdateHardwareQuantity(hardwareId, newQuantity);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* 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-[#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 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]/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>
</div>
</div>
<AnimatePresence>
{voipSelection.enabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* User Count */}
<div className="flex 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-full sm:w-24"
/>
</div>
{/* Tier Selection */}
<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;
return (
<motion.div
key={tier.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -3 }}
>
<Card
variant={isSelected ? 'highlighted' : 'default'}
padding="none"
className={`relative overflow-hidden cursor-pointer h-full ${
tier.recommended && !isSelected ? 'ring-2 ring-[#fe7400]/30' : ''
}`}
onClick={() => onSetVoIPTier(tier.id)}
>
{tier.recommended && (
<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-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]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{formatCurrency(monthlyPrice)}
</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.5 mb-4">
{tier.features.slice(0, 3).map((feature, idx) => (
<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>
<Button
variant={isSelected ? 'primary' : 'outline'}
className="w-full"
size="sm"
>
{isSelected ? 'Selected' : 'Select'}
</Button>
</div>
</Card>
</motion.div>
);
})}
</div>
{/* Hardware Section */}
<div className="border border-gray-200/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-[#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-semibold text-[#333d49]"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
Phone Hardware (Optional)
</span>
</div>
<span className="text-sm text-gray-400 font-medium"
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
{showHardware ? 'Hide' : 'Show'} options
</span>
</button>
<AnimatePresence>
{showHardware && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-4 space-y-3"
>
{voipHardware.map((hardware) => {
const selection = getHardwareSelection(hardware.id);
const isSelected = !!selection;
return (
<div
key={hardware.id}
className={`p-4 rounded-xl border-2 transition-all duration-200 ${
isSelected
? 'border-[#fe7400] bg-[#fe7400]/5'
: 'border-gray-200'
}`}
>
<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-gray-500">
Rent: <strong className="text-[#333d49]">{formatCurrency(hardware.monthlyRental)}</strong>/mo
</span>
</div>
</div>
{isSelected ? (
<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.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
!selection.isRental
? 'bg-[#fe7400] text-white'
: '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.5 py-1.5 text-xs rounded-lg font-semibold transition-colors ${
selection.isRental
? 'bg-[#fe7400] text-white'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
>
Rent
</button>
</div>
<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-50 rounded-l-lg transition-colors"
disabled={selection.quantity <= 1}
>
<Minus className="w-3.5 h-3.5" />
</button>
<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-50 rounded-r-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<button
type="button"
onClick={() => onRemoveHardware(hardware.id)}
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>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleHardwareToggle(hardware.id, false)}
>
Add (Buy)
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleHardwareToggle(hardware.id, true)}
>
Add (Rent)
</Button>
</div>
)}
</div>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Info */}
<ExpandableInfo title="VoIP Features & Benefits">
<ul className="space-y-2.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.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.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.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>
</ExpandableInfo>
{/* Totals */}
<div className="space-y-3">
<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-xs sm:text-sm font-medium opacity-60 ml-1">/mo</span>
</span>
</div>
{getVoIPOneTime() > 0 && (
<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>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{!voipSelection.enabled && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12 text-gray-400"
>
<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>
);
}