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:
2026-03-09 08:14:13 -07:00
parent 06f7f1f4c6
commit 84fce5a621
59 changed files with 14435 additions and 1 deletions

View File

@@ -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>
);
}