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>
421 lines
20 KiB
TypeScript
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 — 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>
|
|
);
|
|
}
|