login
BIN
client/public/images/operators/Mat.jpeg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
27
client/public/images/operators/barrier-heavy.svg
Normal file
@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fff7ed"/>
|
||||
<stop offset="100%" stop-color="#ffedd5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Heavy base -->
|
||||
<rect x="100" y="240" width="200" height="24" rx="8" fill="#78716c"/>
|
||||
<!-- Heavy pole -->
|
||||
<rect x="175" y="30" width="40" height="210" rx="10" fill="#f97316"/>
|
||||
<!-- Heavy barrier arm (raised) -->
|
||||
<rect x="200" y="40" width="180" height="14" rx="6" fill="#ea580c" transform="rotate(-15 200 40)"/>
|
||||
<!-- Stripes -->
|
||||
<polygon points="225,30 240,48 235,50 220,32" fill="#fff" transform="rotate(-15 200 40)"/>
|
||||
<polygon points="260,22 275,40 270,42 255,24" fill="#fff" transform="rotate(-15 200 40)"/>
|
||||
<polygon points="295,14 310,32 305,34 290,16" fill="#fff" transform="rotate(-15 200 40)"/>
|
||||
<polygon points="330,6 345,24 340,26 325,8" fill="#fff" transform="rotate(-15 200 40)"/>
|
||||
<!-- Counterweight -->
|
||||
<rect x="140" y="50" width="40" height="35" rx="6" fill="#ea580c"/>
|
||||
<!-- Dual warning lights -->
|
||||
<circle cx="190" cy="22" r="5" fill="#ef4444"/>
|
||||
<circle cx="210" cy="22" r="5" fill="#ef4444"/>
|
||||
<text x="200" y="270" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#c2410c">BG-500</text>
|
||||
<text x="200" y="288" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Heavy Duty Barrier</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
28
client/public/images/operators/barrier-parking.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fef2f2"/>
|
||||
<stop offset="100%" stop-color="#fee2e2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Base -->
|
||||
<rect x="120" y="240" width="160" height="20" rx="6" fill="#9ca3af"/>
|
||||
<!-- Pole -->
|
||||
<rect x="185" y="40" width="30" height="200" rx="8" fill="#f87171"/>
|
||||
<!-- Barrier arm in raised position (angled up) -->
|
||||
<rect x="200" y="50" width="160" height="10" rx="5" fill="#ef4444" transform="rotate(-20 200 50)"/>
|
||||
<!-- Barrier stripes -->
|
||||
<line x1="220" y1="38" x2="230" y2="55" stroke="#fff" stroke-width="3" transform="rotate(-20 200 50)"/>
|
||||
<line x1="250" y1="30" x2="260" y2="47" stroke="#fff" stroke-width="3" transform="rotate(-20 200 50)"/>
|
||||
<line x1="280" y1="22" x2="290" y2="39" stroke="#fff" stroke-width="3" transform="rotate(-20 200 50)"/>
|
||||
<line x1="310" y1="14" x2="320" y2="31" stroke="#fff" stroke-width="3" transform="rotate(-20 200 50)"/>
|
||||
<line x1="340" y1="6" x2="350" y2="23" stroke="#fff" stroke-width="3" transform="rotate(-20 200 50)"/>
|
||||
<!-- Counterweight -->
|
||||
<rect x="160" y="55" width="30" height="30" rx="5" fill="#f87171"/>
|
||||
<!-- Warning lights on top -->
|
||||
<circle cx="200" cy="30" r="6" fill="#facc15"/>
|
||||
<circle cx="200" cy="30" r="6" fill="#facc15" opacity="0.5"/>
|
||||
<text x="200" y="270" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#dc2626">BG-200</text>
|
||||
<text x="200" y="288" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Parking Barrier</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
client/public/images/operators/liftmaster-csl24.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
client/public/images/operators/liftmaster-csw24.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
client/public/images/operators/liftmaster-ihsl24ul.jpg
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
client/public/images/operators/liftmaster-la500-bundle.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
30
client/public/images/operators/slide-commercial.svg
Normal file
@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f3ff"/>
|
||||
<stop offset="100%" stop-color="#ede9fe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Heavy post -->
|
||||
<rect x="40" y="40" width="18" height="200" rx="6" fill="#a78bfa"/>
|
||||
<!-- Heavy slide gate -->
|
||||
<rect x="58" y="60" width="240" height="14" rx="4" fill="#8b5cf6"/>
|
||||
<rect x="58" y="105" width="240" height="14" rx="4" fill="#8b5cf6"/>
|
||||
<rect x="58" y="150" width="240" height="14" rx="4" fill="#8b5cf6"/>
|
||||
<rect x="58" y="195" width="240" height="14" rx="4" fill="#8b5cf6"/>
|
||||
<!-- Cross bracing -->
|
||||
<line x1="58" y1="60" x2="298" y2="195" stroke="#7c3aed" stroke-width="5" stroke-dasharray="8 5"/>
|
||||
<line x1="298" y1="60" x2="58" y2="195" stroke="#7c3aed" stroke-width="5" stroke-dasharray="8 5"/>
|
||||
<!-- Heavy rollers -->
|
||||
<circle cx="80" cy="240" r="10" fill="#4b5563"/>
|
||||
<circle cx="200" cy="240" r="10" fill="#4b5563"/>
|
||||
<circle cx="320" cy="240" r="10" fill="#4b5563"/>
|
||||
<!-- HD Operator -->
|
||||
<rect x="36" y="34" width="26" height="24" rx="7" fill="#7c3aed"/>
|
||||
<!-- Warning light -->
|
||||
<rect x="280" y="30" width="8" height="20" rx="3" fill="#ef4444"/>
|
||||
<circle cx="284" cy="40" r="6" fill="#ef4444"/>
|
||||
<text x="200" y="270" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#6d28d9">SL-3000</text>
|
||||
<text x="200" y="288" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Commercial Slide</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
27
client/public/images/operators/slide-residential.svg
Normal file
@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ecfdf5"/>
|
||||
<stop offset="100%" stop-color="#d1fae5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Post -->
|
||||
<rect x="50" y="50" width="14" height="180" rx="5" fill="#6ee7b7"/>
|
||||
<!-- Slide gate panel -->
|
||||
<rect x="64" y="75" width="200" height="12" rx="3" fill="#34d399"/>
|
||||
<rect x="64" y="115" width="200" height="12" rx="3" fill="#34d399"/>
|
||||
<rect x="64" y="155" width="200" height="12" rx="3" fill="#34d399"/>
|
||||
<rect x="64" y="195" width="200" height="12" rx="3" fill="#34d399"/>
|
||||
<!-- Diagonal bracing -->
|
||||
<line x1="64" y1="75" x2="264" y2="195" stroke="#10b981" stroke-width="4" stroke-dasharray="6 4"/>
|
||||
<line x1="264" y1="75" x2="64" y2="195" stroke="#10b981" stroke-width="4" stroke-dasharray="6 4"/>
|
||||
<!-- Rollers -->
|
||||
<circle cx="80" cy="230" r="8" fill="#6b7280"/>
|
||||
<circle cx="200" cy="230" r="8" fill="#6b7280"/>
|
||||
<circle cx="320" cy="230" r="8" fill="#6b7280"/>
|
||||
<!-- Operator -->
|
||||
<rect x="46" y="46" width="22" height="20" rx="6" fill="#10b981"/>
|
||||
<text x="200" y="270" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#059669">SL-1500</text>
|
||||
<text x="200" y="288" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Light Commercial Slide</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
31
client/public/images/operators/swing-commercial.svg
Normal file
@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#e0e7ff"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Heavy duty swing gate -->
|
||||
<rect x="30" y="50" width="16" height="180" rx="6" fill="#818cf8"/>
|
||||
<rect x="354" y="50" width="16" height="180" rx="6" fill="#818cf8"/>
|
||||
<!-- Left gate -->
|
||||
<rect x="46" y="65" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="46" y="110" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="46" y="155" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="46" y="200" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<circle cx="195" cy="95" r="6" fill="#facc15"/>
|
||||
<!-- Right gate -->
|
||||
<rect x="209" y="65" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="209" y="110" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="209" y="155" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<rect x="209" y="200" width="145" height="12" rx="4" fill="#6366f1"/>
|
||||
<circle cx="205" cy="95" r="6" fill="#facc15"/>
|
||||
<!-- Heavy duty operator -->
|
||||
<rect x="26" y="44" width="24" height="24" rx="6" fill="#4f46e5"/>
|
||||
<!-- Warning light -->
|
||||
<rect x="100" y="40" width="8" height="18" rx="3" fill="#ef4444"/>
|
||||
<circle cx="104" cy="48" r="5" fill="#ef4444"/>
|
||||
<text x="200" y="268" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#4f46e5">SG-2000</text>
|
||||
<text x="200" y="286" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Commercial Swing</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
28
client/public/images/operators/swing-residential.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#e8f0fe"/>
|
||||
<stop offset="100%" stop-color="#d0e1fd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" rx="12" fill="url(#bg)"/>
|
||||
<!-- Swing gate illustration -->
|
||||
<rect x="40" y="60" width="12" height="160" rx="4" fill="#93c5fd"/>
|
||||
<rect x="348" y="60" width="12" height="160" rx="4" fill="#93c5fd"/>
|
||||
<!-- Left gate panel -->
|
||||
<rect x="52" y="80" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="52" y="120" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="52" y="160" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="52" y="200" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<circle cx="185" cy="108" r="5" fill="#facc15"/>
|
||||
<!-- Right gate panel -->
|
||||
<rect x="218" y="80" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="218" y="120" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="218" y="160" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<rect x="218" y="200" width="130" height="8" rx="3" fill="#3b82f6"/>
|
||||
<circle cx="215" cy="108" r="5" fill="#facc15"/>
|
||||
<!-- Operator unit -->
|
||||
<rect x="38" y="56" width="20" height="18" rx="5" fill="#60a5fa"/>
|
||||
<text x="200" y="270" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#3b82f6">SG-1000</text>
|
||||
<text x="200" y="288" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#94a3b8">Residential Swing</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
BIN
client/public/images/operators/techno.jpeg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
@ -1,46 +1,90 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Wizard from './components/Wizard';
|
||||
import pricingData from './data/pricing.json';
|
||||
import Login from './components/Login';
|
||||
|
||||
export default function App() {
|
||||
const [pricing, setPricing] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
|
||||
const fetchPricing = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/pricing', { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setAuthenticated(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error('API unavailable');
|
||||
}
|
||||
const data = await res.json();
|
||||
setPricing(data);
|
||||
setAuthenticated(true);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setPricing(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/pricing')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('API unavailable');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setPricing(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setPricing(pricingData);
|
||||
setLoading(false);
|
||||
fetchPricing();
|
||||
}, [fetchPricing]);
|
||||
|
||||
const handleLogin = () => {
|
||||
setAuthenticated(true);
|
||||
setLoading(true);
|
||||
fetchPricing();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
}, []);
|
||||
} catch {
|
||||
// Proceed even if logout request fails
|
||||
}
|
||||
setAuthenticated(false);
|
||||
setPricing(null);
|
||||
};
|
||||
|
||||
if (!authenticated && !loading) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pricing) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-red-600">Failed to load pricing data</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 font-medium mb-2">Failed to load pricing data</div>
|
||||
<button
|
||||
onClick={() => { setLoading(true); fetchPricing(); }}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Wizard pricing={pricing} />
|
||||
<Wizard pricing={pricing} onLogout={handleLogout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
client/src/components/Login.jsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
onLogin();
|
||||
} else {
|
||||
setError('Invalid password. Please try again.');
|
||||
setPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError('Connection error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Gate Quote
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2">
|
||||
Enter the access code to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Access Code
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter access code"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 text-lg text-center border-2 border-gray-200 rounded-xl placeholder-gray-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || loading}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" 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>
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Unlock'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-xs text-gray-400 text-center">
|
||||
Gate Operator Quotation System
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,10 @@ import { calculateQuote } from '../utils/quoteCalculator';
|
||||
export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
const quoteRef = useRef(null);
|
||||
const quote = calculateQuote(pricing, selections);
|
||||
const operator = pricing.operators.find((o) => o.id === selections.operator);
|
||||
const operator =
|
||||
selections.operator && typeof selections.operator === 'object'
|
||||
? selections.operator
|
||||
: pricing.operators.find((o) => o.id === selections.operator);
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
@ -40,7 +43,8 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
heightLeft -= pageHeight - margin;
|
||||
}
|
||||
|
||||
pdf.save(`Gate_Quote_${operator?.model || 'Quote'}.pdf`);
|
||||
const modelName = operator?.tiltModel || operator?.model || 'Quote';
|
||||
pdf.save('Gate_Quote_' + modelName + '.pdf');
|
||||
};
|
||||
|
||||
return (
|
||||
@ -89,12 +93,31 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
</div>
|
||||
|
||||
{operator && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
|
||||
Selected Operator
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4 flex items-start gap-4">
|
||||
{operator.imageFile && !operator.tiltModel && (
|
||||
<img
|
||||
src={'/images/operators/' + operator.imageFile}
|
||||
alt={operator.name}
|
||||
className="w-24 h-24 rounded-lg object-cover shrink-0 border border-gray-200"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
|
||||
Selected Operator
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{operator.tiltName || operator.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {operator.tiltModel || operator.model}
|
||||
</div>
|
||||
{operator.tiltDescription && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{operator.tiltDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900">{operator.name}</div>
|
||||
<div className="text-sm text-gray-500">Model: {operator.model}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -152,9 +175,9 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-300 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<div className="border-t border-gray-300 pt-4">
|
||||
<div className="flex justify-between text-lg font-bold text-gray-900">
|
||||
<span>Total Cost</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.subtotal.toLocaleString(pricing.currency.locale, {
|
||||
@ -163,26 +186,6 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>HST ({((quote.taxRate ?? 0) * 100).toFixed(0)}%)</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.tax.toLocaleString(pricing.currency.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold text-gray-900 border-t border-gray-200 pt-2">
|
||||
<span>Total Estimate</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.total.toLocaleString(pricing.currency.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-400 text-center border-t border-gray-200 pt-4">
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
export default function StepAccessControl({
|
||||
accessControl,
|
||||
remoteButtonOptions,
|
||||
selected,
|
||||
remoteButtons,
|
||||
remoteQuantity,
|
||||
onToggle,
|
||||
onSetRemoteButtons,
|
||||
onSetRemoteQuantity,
|
||||
onBack,
|
||||
onNext,
|
||||
}) {
|
||||
@ -29,44 +34,129 @@ export default function StepAccessControl({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{accessControl.map((ac) => {
|
||||
const isSelected = selected.includes(ac.id);
|
||||
const isRemote = ac.id === 'remote-kit';
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={ac.id}
|
||||
onClick={() => onToggle(ac.id)}
|
||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
||||
className={`rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">{ac.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {ac.model}
|
||||
<button
|
||||
onClick={() => onToggle(ac.id)}
|
||||
className={`text-left w-full p-4 ${isSelected && isRemote ? 'pb-3' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">{ac.name}</div>
|
||||
{ac.model && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {ac.model}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{ac.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
{isRemote ? (
|
||||
'Varies by buttons'
|
||||
) : (
|
||||
'C$' + ac.price.toLocaleString('en-CA')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{ac.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${ac.price.toLocaleString('en-CA')}
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && isRemote && (
|
||||
<div className="px-4 pt-3 pb-4 border-t border-blue-200 mt-0 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Number of Buttons
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{remoteButtonOptions.map((opt) => {
|
||||
const isBtnSelected = remoteButtons === Number(opt.id);
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteButtons(Number(opt.id));
|
||||
}}
|
||||
className={`px-3 py-2 text-sm rounded-lg border-2 transition-all text-center ${
|
||||
isBtnSelected
|
||||
? 'border-blue-600 bg-white shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${isBtnSelected ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{opt.id}-Button
|
||||
</div>
|
||||
<div className={`text-xs mt-0.5 ${isBtnSelected ? 'text-blue-500' : 'text-gray-400'}`}>
|
||||
C${opt.price.toLocaleString('en-CA')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteQuantity(remoteQuantity - 1);
|
||||
}}
|
||||
disabled={remoteQuantity <= 1}
|
||||
className={`w-9 h-9 rounded-lg border-2 flex items-center justify-center text-lg font-medium transition-all ${
|
||||
remoteQuantity <= 1
|
||||
? 'border-gray-200 text-gray-300 cursor-not-allowed'
|
||||
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="w-14 h-9 rounded-lg border-2 border-gray-300 flex items-center justify-center text-sm font-semibold text-gray-900 bg-white">
|
||||
{remoteQuantity}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteQuantity(remoteQuantity + 1);
|
||||
}}
|
||||
className="w-9 h-9 rounded-lg border-2 border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:border-gray-400 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
{remoteQuantity > 1 ? 'remotes' : 'remote'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
export default function StepGroundLoops({
|
||||
detectors,
|
||||
styles,
|
||||
sizes,
|
||||
types,
|
||||
value,
|
||||
onSetNeeded,
|
||||
onSetStyle,
|
||||
onToggleType,
|
||||
onSetSize,
|
||||
onNext,
|
||||
onBack,
|
||||
}) {
|
||||
const { needed, style, types: selectedTypes } = value;
|
||||
const { needed, style, types: selectedTypes, typeSizes } = value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -118,43 +121,95 @@ export default function StepGroundLoops({
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{types.map((t) => {
|
||||
const isSelected = selectedTypes.includes(t.id);
|
||||
const currentSize = typeSizes[t.id];
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => onToggleType(t.id)}
|
||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
||||
className={`rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{t.name}
|
||||
<button
|
||||
onClick={() => onToggleType(t.id)}
|
||||
className={`text-left w-full p-4 ${isSelected ? 'pb-3' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{t.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${t.price.toLocaleString('en-CA')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{t.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${t.price.toLocaleString('en-CA')}
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</button>
|
||||
|
||||
{isSelected && (
|
||||
<div className="px-4 pb-4 border-t border-blue-200 pt-3 mt-0 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-100 rounded-md px-3 py-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Includes 1 Loop Detector (+C${(detectors?.[0]?.price ?? 250).toLocaleString('en-CA')})</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Loop Size
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{sizes.map((s) => {
|
||||
const isSizeSelected = currentSize === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetSize(t.id, s.id);
|
||||
}}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-lg border-2 transition-all ${
|
||||
isSizeSelected
|
||||
? 'border-blue-600 bg-white shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${isSizeSelected ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{s.name}
|
||||
</div>
|
||||
{s.additionalCost > 0 && (
|
||||
<div className={`text-xs mt-0.5 ${isSizeSelected ? 'text-blue-500' : 'text-gray-400'}`}>
|
||||
+C${s.additionalCost.toLocaleString('en-CA')}
|
||||
</div>
|
||||
)}
|
||||
{s.additionalCost === 0 && (
|
||||
<div className="text-xs mt-0.5 text-gray-400">Standard</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,36 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const categoryLabels = {
|
||||
swing: 'Swing Gate Operators',
|
||||
slide: 'Slide Gate Operators',
|
||||
barrier: 'Barrier Gate Operators',
|
||||
};
|
||||
|
||||
const categoryIcons = {
|
||||
swing: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<rect x="36" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<line x1="8" y1="20" x2="40" y2="20" stroke="currentColor" strokeWidth="3" className="stroke-blue-600" />
|
||||
<line x1="8" y1="28" x2="36" y2="28" stroke="currentColor" strokeWidth="2" className="stroke-blue-400" strokeDasharray="2 2" />
|
||||
<circle cx="32" cy="28" r="3" className="fill-yellow-400" />
|
||||
</svg>
|
||||
),
|
||||
slide: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<rect x="36" y="16" width="8" height="24" rx="1" className="fill-blue-200" />
|
||||
<rect x="12" y="24" width="24" height="6" rx="1" className="fill-blue-600" />
|
||||
<circle cx="14" cy="16" r="2" className="fill-gray-400" />
|
||||
<circle cx="14" cy="36" r="2" className="fill-gray-400" />
|
||||
</svg>
|
||||
),
|
||||
barrier: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="6" height="34" rx="2" className="fill-blue-200" />
|
||||
<rect x="22" y="8" width="6" height="34" rx="2" className="fill-blue-200" />
|
||||
<rect x="2" y="12" width="28" height="6" rx="1" className="fill-blue-600" />
|
||||
<rect x="26" y="14" width="34" height="4" rx="1" className="fill-yellow-400" transform="rotate(30 26 14)" />
|
||||
</svg>
|
||||
),
|
||||
tilt: 'Tilt Gate Operators',
|
||||
};
|
||||
|
||||
export default function StepOperator({ operators, selected, onSelect }) {
|
||||
@ -52,53 +26,186 @@ export default function StepOperator({ operators, selected, onSelect }) {
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{operators
|
||||
.filter((o) => o.category === cat)
|
||||
.map((op) => (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => onSelect(op.id)}
|
||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selected === op.id
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`shrink-0 p-2 rounded-lg ${
|
||||
selected === op.id ? 'bg-blue-100' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{categoryIcons[cat]}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{op.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
Model: {op.model}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{op.description}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
C${op.basePrice.toLocaleString('en-CA')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">base</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
||||
{op.requiredParts.length} parts included
|
||||
.filter((o) => o.category === cat && !o.isCustom)
|
||||
.map((op) => {
|
||||
const isSelected = selected === op.id;
|
||||
return (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => onSelect(op.id)}
|
||||
className={`text-left p-0 rounded-xl border-2 transition-all overflow-hidden ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:w-44 shrink-0 bg-gray-50 flex items-center justify-center relative">
|
||||
<img
|
||||
src={'/images/operators/' + op.imageFile}
|
||||
alt={op.name}
|
||||
className="w-full h-44 sm:h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex'; }}
|
||||
/>
|
||||
<span className="flex items-center justify-center text-gray-400 text-sm p-4 text-center absolute inset-0" style={{display: 'none'}}>
|
||||
{op.model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-5">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{op.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
Model: {op.model}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{op.description}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
C{op.basePrice.toLocaleString('en-CA')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">base</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
||||
{op.requiredParts.length} parts included
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{cat === 'tilt' && <TiltOperatorCard selected={selected} onSelect={onSelect} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TiltOperatorCard({ selected, onSelect }) {
|
||||
const [model, setModel] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const isTiltSelected = selected && typeof selected === 'object';
|
||||
|
||||
const handleSelect = () => {
|
||||
const newErrors = {};
|
||||
if (!model.trim()) newErrors.model = 'Model is required';
|
||||
if (!description.trim()) newErrors.description = 'Description is required';
|
||||
if (!price || isNaN(Number(price)) || Number(price) <= 0) newErrors.price = 'Enter a valid price';
|
||||
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
onSelect({
|
||||
id: 'tilt',
|
||||
tiltModel: model.trim(),
|
||||
tiltDescription: description.trim(),
|
||||
tiltPrice: Number(price),
|
||||
tiltName: 'Tilt Gate Operator',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselect = () => {
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
const displayModel = isTiltSelected ? selected.tiltModel : model;
|
||||
const displayDescription = isTiltSelected ? selected.tiltDescription : description;
|
||||
const displayPrice = isTiltSelected ? selected.tiltPrice : price;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border-2 transition-all overflow-hidden ${
|
||||
isTiltSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-semibold text-gray-900">Custom Tilt Gate Operator</div>
|
||||
{isTiltSelected && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. TG-500"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.model ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.model && <p className="text-xs text-red-500 mt-1">{errors.model}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayDescription}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. Heavy-duty tilt gate operator, 24V DC"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.description ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Price (C$)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={displayPrice}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. 2995"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.price ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.price && <p className="text-xs text-red-500 mt-1">{errors.price}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isTiltSelected ? (
|
||||
<button
|
||||
onClick={handleDeselect}
|
||||
className="w-full py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Deselect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSelect}
|
||||
className="w-full py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Select Custom Tilt Operator
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,8 +11,11 @@ const initialState = {
|
||||
needed: false,
|
||||
style: null,
|
||||
types: [],
|
||||
typeSizes: {},
|
||||
},
|
||||
accessControl: [],
|
||||
remoteButtons: 4,
|
||||
remoteQuantity: 1,
|
||||
};
|
||||
|
||||
function reducer(state, action) {
|
||||
@ -22,7 +25,7 @@ function reducer(state, action) {
|
||||
case 'SET_GROUND_LOOPS_NEEDED':
|
||||
return {
|
||||
...state,
|
||||
groundLoops: { needed: action.payload, style: null, types: [] },
|
||||
groundLoops: { needed: action.payload, style: null, types: [], typeSizes: {} },
|
||||
step: action.payload ? state.step : state.step + 1,
|
||||
};
|
||||
case 'SET_GROUND_LOOP_STYLE':
|
||||
@ -33,6 +36,12 @@ function reducer(state, action) {
|
||||
case 'TOGGLE_GROUND_LOOP_TYPE': {
|
||||
const id = action.payload;
|
||||
const exists = state.groundLoops.types.includes(id);
|
||||
const newTypeSizes = { ...state.groundLoops.typeSizes };
|
||||
if (!exists) {
|
||||
newTypeSizes[id] = '4x8';
|
||||
} else {
|
||||
delete newTypeSizes[id];
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
groundLoops: {
|
||||
@ -40,6 +49,7 @@ function reducer(state, action) {
|
||||
types: exists
|
||||
? state.groundLoops.types.filter((t) => t !== id)
|
||||
: [...state.groundLoops.types, id],
|
||||
typeSizes: newTypeSizes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -51,12 +61,28 @@ function reducer(state, action) {
|
||||
accessControl: exists
|
||||
? state.accessControl.filter((a) => a !== id)
|
||||
: [...state.accessControl, id],
|
||||
remoteButtons: id === 'remote-kit' && !exists ? 4 : state.remoteButtons,
|
||||
};
|
||||
}
|
||||
case 'SET_REMOTE_BUTTONS':
|
||||
return { ...state, remoteButtons: action.payload };
|
||||
case 'SET_REMOTE_QUANTITY':
|
||||
return { ...state, remoteQuantity: Math.max(1, action.payload) };
|
||||
case 'NEXT_STEP':
|
||||
return { ...state, step: Math.min(state.step + 1, 4) };
|
||||
case 'PREV_STEP':
|
||||
return { ...state, step: Math.max(state.step - 1, 1) };
|
||||
case 'SET_GROUND_LOOP_SIZE':
|
||||
return {
|
||||
...state,
|
||||
groundLoops: {
|
||||
...state.groundLoops,
|
||||
typeSizes: {
|
||||
...state.groundLoops.typeSizes,
|
||||
[action.payload.typeId]: action.payload.sizeId,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'GO_TO_STEP':
|
||||
return { ...state, step: Math.min(action.payload, 4) };
|
||||
case 'RESET':
|
||||
@ -66,9 +92,9 @@ function reducer(state, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Wizard({ pricing }) {
|
||||
export default function Wizard({ pricing, onLogout }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { step, operator, groundLoops, accessControl } = state;
|
||||
const { step, operator, groundLoops, accessControl, remoteButtons, remoteQuantity } = state;
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: 'Operator' },
|
||||
@ -79,7 +105,17 @@ export default function Wizard({ pricing }) {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-center mb-8 relative">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="absolute right-0 top-0 text-xs text-gray-400 hover:text-gray-600 transition-colors flex items-center gap-1"
|
||||
title="Logout"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Gate Operator Quotation System
|
||||
</h1>
|
||||
@ -141,12 +177,15 @@ export default function Wizard({ pricing }) {
|
||||
|
||||
{step === 2 && (
|
||||
<StepGroundLoops
|
||||
detectors={pricing.loopDetectors}
|
||||
styles={pricing.groundLoopStyles}
|
||||
sizes={pricing.groundLoopSizes}
|
||||
types={pricing.groundLoopTypes}
|
||||
value={groundLoops}
|
||||
onSetNeeded={(needed) => dispatch({ type: 'SET_GROUND_LOOPS_NEEDED', payload: needed })}
|
||||
onSetStyle={(id) => dispatch({ type: 'SET_GROUND_LOOP_STYLE', payload: id })}
|
||||
onToggleType={(id) => dispatch({ type: 'TOGGLE_GROUND_LOOP_TYPE', payload: id })}
|
||||
onSetSize={(typeId, sizeId) => dispatch({ type: 'SET_GROUND_LOOP_SIZE', payload: { typeId, sizeId } })}
|
||||
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
/>
|
||||
@ -155,8 +194,13 @@ export default function Wizard({ pricing }) {
|
||||
{step === 3 && (
|
||||
<StepAccessControl
|
||||
accessControl={pricing.accessControl}
|
||||
remoteButtonOptions={pricing.remoteButtonOptions}
|
||||
selected={accessControl}
|
||||
remoteButtons={remoteButtons}
|
||||
remoteQuantity={remoteQuantity}
|
||||
onToggle={(id) => dispatch({ type: 'TOGGLE_ACCESS_CONTROL', payload: id })}
|
||||
onSetRemoteButtons={(count) => dispatch({ type: 'SET_REMOTE_BUTTONS', payload: count })}
|
||||
onSetRemoteQuantity={(qty) => dispatch({ type: 'SET_REMOTE_QUANTITY', payload: qty })}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||
/>
|
||||
@ -165,7 +209,7 @@ export default function Wizard({ pricing }) {
|
||||
{step === 4 && (
|
||||
<QuoteSummary
|
||||
pricing={pricing}
|
||||
selections={{ operator, groundLoops, accessControl }}
|
||||
selections={{ operator, groundLoops, accessControl, remoteButtons, remoteQuantity }}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
onNew={() => dispatch({ type: 'RESET' })}
|
||||
/>
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
{
|
||||
"currency": {
|
||||
"symbol": "C$",
|
||||
"code": "CAD",
|
||||
"locale": "en-CA"
|
||||
},
|
||||
"operators": [
|
||||
{
|
||||
"id": "swing-residential",
|
||||
"name": "Residential Swing Gate Operator",
|
||||
"model": "SG-1000",
|
||||
"category": "swing",
|
||||
"description": "12V DC swing gate operator for residential gates up to 14 ft",
|
||||
"basePrice": 2495,
|
||||
"image": "swing",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-swing", "name": "Operator Unit (SG-1000)", "qty": 2, "unitPrice": 1247.50 },
|
||||
{ "id": "bracket-kit", "name": "Gate Bracket Kit", "qty": 2, "unitPrice": 85 },
|
||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
||||
{ "id": "hinges", "name": "Gate Hinges (Heavy Duty)", "qty": 4, "unitPrice": 45 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "swing-commercial",
|
||||
"name": "Commercial Swing Gate Operator",
|
||||
"model": "SG-2000",
|
||||
"category": "swing",
|
||||
"description": "24V DC heavy-duty swing operator for commercial gates up to 24 ft",
|
||||
"basePrice": 3995,
|
||||
"image": "swing",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-swing-hd", "name": "Heavy Duty Operator Unit (SG-2000)", "qty": 2, "unitPrice": 1997.50 },
|
||||
{ "id": "bracket-kit-hd", "name": "Commercial Bracket Kit", "qty": 2, "unitPrice": 145 },
|
||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
||||
{ "id": "hinges-hd", "name": "Commercial Gate Hinges", "qty": 4, "unitPrice": 75 },
|
||||
{ "id": "warning-light", "name": "Strobe Warning Light", "qty": 1, "unitPrice": 195 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "slide-residential",
|
||||
"name": "Light Commercial Slide Gate Operator",
|
||||
"model": "SL-1500",
|
||||
"category": "slide",
|
||||
"description": "12V DC slide operator for residential and light commercial gates up to 20 ft",
|
||||
"basePrice": 2495,
|
||||
"image": "slide",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-slide-lc", "name": "Slide Operator Unit (SL-1500)", "qty": 1, "unitPrice": 2495 },
|
||||
{ "id": "rack-kit-lc", "name": "Slide Gate Rack Kit (10ft sections)", "qty": 2, "unitPrice": 175 },
|
||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "slide-commercial",
|
||||
"name": "Commercial Slide Gate Operator",
|
||||
"model": "SL-3000",
|
||||
"category": "slide",
|
||||
"description": "24V DC slide gate operator for commercial gates up to 40 ft",
|
||||
"basePrice": 3895,
|
||||
"image": "slide",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-slide", "name": "Heavy Duty Operator Unit (SL-3000)", "qty": 1, "unitPrice": 3895 },
|
||||
{ "id": "rack-kit", "name": "Slide Gate Rack Kit (10ft sections)", "qty": 4, "unitPrice": 175 },
|
||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
||||
{ "id": "warning-light", "name": "Strobe Warning Light", "qty": 1, "unitPrice": 195 },
|
||||
{ "id": "sensor-loop", "name": "Vehicle Loop Sensor", "qty": 1, "unitPrice": 285 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "barrier-parking",
|
||||
"name": "Parking Barrier Gate Operator",
|
||||
"model": "BG-200",
|
||||
"category": "barrier",
|
||||
"description": "120V AC barrier gate for parking lots and access control up to 20 ft",
|
||||
"basePrice": 3195,
|
||||
"image": "barrier",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-barrier", "name": "Barrier Arm Assembly (BG-200)", "qty": 1, "unitPrice": 3195 },
|
||||
{ "id": "barrier-arm", "name": "Barrier Arm (8ft Aluminum)", "qty": 1, "unitPrice": 395 },
|
||||
{ "id": "loop-detector", "name": "Loop Detector Card", "qty": 1, "unitPrice": 345 },
|
||||
{ "id": "warning-light", "name": "LED Warning Light", "qty": 2, "unitPrice": 145 },
|
||||
{ "id": "signage", "name": "Gate Signage Kit", "qty": 1, "unitPrice": 120 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "barrier-heavy",
|
||||
"name": "Heavy Duty Barrier Gate Operator",
|
||||
"model": "BG-500",
|
||||
"category": "barrier",
|
||||
"description": "230V AC heavy-duty barrier gate for industrial sites up to 30 ft",
|
||||
"basePrice": 5495,
|
||||
"image": "barrier",
|
||||
"requiredParts": [
|
||||
{ "id": "op-unit-barrier-hd", "name": "Heavy Duty Barrier Assembly (BG-500)", "qty": 1, "unitPrice": 5495 },
|
||||
{ "id": "barrier-arm-hd", "name": "Barrier Arm (16ft Steel)", "qty": 1, "unitPrice": 695 },
|
||||
{ "id": "loop-detector", "name": "Loop Detector Card", "qty": 2, "unitPrice": 345 },
|
||||
{ "id": "warning-light-hd", "name": "LED Warning Light Kit", "qty": 2, "unitPrice": 195 },
|
||||
{ "id": "signage", "name": "Gate Signage Kit", "qty": 1, "unitPrice": 120 },
|
||||
{ "id": "safety-edge", "name": "Safety Edge Sensor", "qty": 1, "unitPrice": 295 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"groundLoopStyles": [
|
||||
{ "id": "saw-cut", "name": "Saw-Cut Installation", "description": "Loop cut into existing pavement and sealed flush", "additionalCost": 0 },
|
||||
{ "id": "pave-over", "name": "Pave-Over Installation", "description": "Surface-mount loop installed on top of pavement with protective casing", "additionalCost": 195 }
|
||||
],
|
||||
"groundLoopTypes": [
|
||||
{ "id": "interrupt", "name": "Interrupt Loop", "description": "Primary vehicle detection loop for gate activation", "price": 425 },
|
||||
{ "id": "shadow", "name": "Shadow Loop", "description": "Secondary safety loop located behind gate to prevent closure on vehicle", "price": 375 },
|
||||
{ "id": "exit", "name": "Exit Loop", "description": "Free exit loop on departure side for automatic exit detection", "price": 375 }
|
||||
],
|
||||
"accessControl": [
|
||||
{ "id": "keypad", "name": "Digital Keypad", "model": "KP-200", "description": "Weatherproof digital keypad with backlit keys, 500 user codes", "price": 295 },
|
||||
{ "id": "card-reader", "name": "Proximity Card Reader", "model": "PR-500", "description": "125kHz proximity card reader with 1000 card capacity", "price": 445 },
|
||||
{ "id": "intercom", "name": "Audio/Video Intercom", "model": "AV-700", "description": "2-wire audio/video intercom with color camera", "price": 895 },
|
||||
{ "id": "remote-kit", "name": "Remote Control Kit", "model": "RC-4", "description": "4-button remote control with 2 remotes, rolling code", "price": 195 },
|
||||
{ "id": "solar-kit", "name": "Solar Panel Kit", "model": "SP-100", "description": "100W solar panel with charge controller and battery", "price": 695 },
|
||||
{ "id": "gsm-controller", "name": "GSM Cellular Controller", "model": "GSM-4G", "description": "4G cellular controller with app, no phone line needed", "price": 595 }
|
||||
]
|
||||
}
|
||||
@ -4,20 +4,35 @@ export function calculateQuote(pricing, selections) {
|
||||
let subtotal = 0;
|
||||
|
||||
if (operator) {
|
||||
const op = pricing.operators.find((o) => o.id === operator);
|
||||
if (op) {
|
||||
op.requiredParts.forEach((part) => {
|
||||
const lineTotal = part.qty * part.unitPrice;
|
||||
items.push({
|
||||
type: 'part',
|
||||
category: 'Required Equipment',
|
||||
name: part.name,
|
||||
qty: part.qty,
|
||||
unitPrice: part.unitPrice,
|
||||
lineTotal,
|
||||
});
|
||||
subtotal += lineTotal;
|
||||
// Custom tilt operator (object with tiltModel, tiltDescription, tiltPrice)
|
||||
if (typeof operator === 'object' && operator.tiltModel) {
|
||||
items.push({
|
||||
type: 'tiltOperator',
|
||||
category: 'Required Equipment',
|
||||
name: operator.tiltName + ' (' + operator.tiltModel + ')',
|
||||
description: operator.tiltDescription,
|
||||
qty: 1,
|
||||
unitPrice: operator.tiltPrice,
|
||||
lineTotal: operator.tiltPrice,
|
||||
});
|
||||
subtotal += operator.tiltPrice;
|
||||
} else {
|
||||
// Pre-defined operator by ID
|
||||
const op = pricing.operators.find((o) => o.id === operator);
|
||||
if (op) {
|
||||
op.requiredParts.forEach((part) => {
|
||||
const lineTotal = part.qty * part.unitPrice;
|
||||
items.push({
|
||||
type: 'part',
|
||||
category: 'Required Equipment',
|
||||
name: part.name,
|
||||
qty: part.qty,
|
||||
unitPrice: part.unitPrice,
|
||||
lineTotal,
|
||||
});
|
||||
subtotal += lineTotal;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +43,7 @@ export function calculateQuote(pricing, selections) {
|
||||
items.push({
|
||||
type: 'groundLoopStyle',
|
||||
category: 'Ground Loops',
|
||||
name: `Installation: ${gls.name}`,
|
||||
name: 'Installation: ' + gls.name,
|
||||
qty: 1,
|
||||
unitPrice: gls.additionalCost,
|
||||
lineTotal: gls.additionalCost,
|
||||
@ -38,18 +53,40 @@ export function calculateQuote(pricing, selections) {
|
||||
}
|
||||
|
||||
if (groundLoops.types && groundLoops.types.length > 0) {
|
||||
// Default detector pricing
|
||||
const detectorPrice = pricing.loopDetectors?.[0]?.price ?? 250;
|
||||
|
||||
groundLoops.types.forEach((typeId) => {
|
||||
const glt = pricing.groundLoopTypes.find((t) => t.id === typeId);
|
||||
if (glt) {
|
||||
// Look up the size for this loop type
|
||||
const sizeId = groundLoops.typeSizes?.[typeId];
|
||||
const size = pricing.groundLoopSizes?.find((s) => s.id === sizeId);
|
||||
const sizeCost = size?.additionalCost ?? 0;
|
||||
const lineTotal = glt.price + sizeCost;
|
||||
|
||||
items.push({
|
||||
type: 'groundLoopType',
|
||||
category: 'Ground Loops',
|
||||
name: glt.name,
|
||||
name: glt.name + (size ? ' (' + size.name + ')' : ''),
|
||||
qty: 1,
|
||||
unitPrice: glt.price,
|
||||
lineTotal: glt.price,
|
||||
sizeCost: sizeCost,
|
||||
sizeName: size?.name || null,
|
||||
lineTotal,
|
||||
});
|
||||
subtotal += glt.price;
|
||||
subtotal += lineTotal;
|
||||
|
||||
// Add loop detector for this loop type
|
||||
items.push({
|
||||
type: 'loopDetector',
|
||||
category: 'Ground Loops',
|
||||
name: 'Loop Detector (' + glt.name + ')',
|
||||
qty: 1,
|
||||
unitPrice: detectorPrice,
|
||||
lineTotal: detectorPrice,
|
||||
});
|
||||
subtotal += detectorPrice;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -57,24 +94,38 @@ export function calculateQuote(pricing, selections) {
|
||||
|
||||
if (accessControl && accessControl.length > 0) {
|
||||
accessControl.forEach((acId) => {
|
||||
const ac = pricing.accessControl.find((a) => a.id === acId);
|
||||
if (ac) {
|
||||
items.push({
|
||||
type: 'accessControl',
|
||||
category: 'Access Control',
|
||||
name: ac.name,
|
||||
qty: 1,
|
||||
unitPrice: ac.price,
|
||||
lineTotal: ac.price,
|
||||
});
|
||||
subtotal += ac.price;
|
||||
if (acId === 'remote-kit') {
|
||||
const buttonCount = selections.remoteButtons || 4;
|
||||
const qty = selections.remoteQuantity || 1;
|
||||
const option = pricing.remoteButtonOptions?.find((o) => Number(o.id) === buttonCount);
|
||||
if (option) {
|
||||
const lineTotal = option.price * qty;
|
||||
items.push({
|
||||
type: 'accessControl',
|
||||
category: 'Access Control',
|
||||
name: option.name + (qty > 1 ? ' (x' + qty + ')' : ''),
|
||||
qty: qty,
|
||||
unitPrice: option.price,
|
||||
lineTotal,
|
||||
});
|
||||
subtotal += lineTotal;
|
||||
}
|
||||
} else {
|
||||
const ac = pricing.accessControl.find((a) => a.id === acId);
|
||||
if (ac) {
|
||||
items.push({
|
||||
type: 'accessControl',
|
||||
category: 'Access Control',
|
||||
name: ac.name,
|
||||
qty: 1,
|
||||
unitPrice: ac.price,
|
||||
lineTotal: ac.price,
|
||||
});
|
||||
subtotal += ac.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const taxRate = 0.13;
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
|
||||
return { items, subtotal, tax, taxRate, total };
|
||||
return { items, subtotal, total: subtotal };
|
||||
}
|
||||
|
||||