sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13
Synced files: - Session logs updated - Latest context and credentials - Command/directive updates Machine: Mikes-MacBook-Air.local Timestamp: 2026-03-09 08:14:13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
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"
|
||||
>
|
||||
{/* 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">
|
||||
Modern VoIP phone system with advanced features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<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">
|
||||
{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 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>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={voipSelection.userCount}
|
||||
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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: -4 }}
|
||||
>
|
||||
<Card
|
||||
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||
padding="none"
|
||||
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||
}`}
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<div className="mb-3">
|
||||
<span className="text-xl font-bold text-[#333d49]">
|
||||
{formatCurrency(monthlyPrice)}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs">/mo</span>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatCurrency(tier.pricePerUser)}/user
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1 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>
|
||||
))}
|
||||
</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 rounded-lg overflow-hidden">
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Headphones className="w-5 h-5 text-[#fe7400]" />
|
||||
<span className="font-medium text-[#333d49]">
|
||||
Phone Hardware (Optional)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{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-lg border-2 transition-all ${
|
||||
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>
|
||||
</span>
|
||||
<span className="text-[#333d49]">
|
||||
Rent: <strong>{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
!selection.isRental
|
||||
? 'bg-[#fe7400] text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
selection.isRental
|
||||
? 'bg-[#fe7400] text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Rent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(hardware.id, -1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-l-lg"
|
||||
disabled={selection.quantity <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center font-medium">
|
||||
{selection.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuantityChange(hardware.id, 1)}
|
||||
className="p-2 hover:bg-gray-100 rounded-r-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveHardware(hardware.id)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<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">
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<span>Keep your existing phone numbers</span>
|
||||
</li>
|
||||
</ul>
|
||||
</ExpandableInfo>
|
||||
|
||||
{/* 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">
|
||||
{formatCurrency(getVoIPMonthly())}
|
||||
<span className="text-lg font-normal opacity-75">/month</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]">
|
||||
{formatCurrency(getVoIPOneTime())}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!voipSelection.enabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-8 text-gray-500"
|
||||
>
|
||||
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>You can always add VoIP services later.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user