login
3
.gitignore
vendored
@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|||||||
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 Wizard from './components/Wizard';
|
||||||
import pricingData from './data/pricing.json';
|
import Login from './components/Login';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [pricing, setPricing] = useState(null);
|
const [pricing, setPricing] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/pricing')
|
fetchPricing();
|
||||||
.then((res) => {
|
}, [fetchPricing]);
|
||||||
if (!res.ok) throw new Error('API unavailable');
|
|
||||||
return res.json();
|
const handleLogin = () => {
|
||||||
})
|
setAuthenticated(true);
|
||||||
.then((data) => {
|
setLoading(true);
|
||||||
setPricing(data);
|
fetchPricing();
|
||||||
setLoading(false);
|
};
|
||||||
})
|
|
||||||
.catch(() => {
|
const handleLogout = async () => {
|
||||||
setPricing(pricingData);
|
try {
|
||||||
setLoading(false);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pricing) {
|
if (!pricing) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="text-red-600">Failed to load pricing data</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Wizard pricing={pricing} />
|
<Wizard pricing={pricing} onLogout={handleLogout} />
|
||||||
</div>
|
</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 }) {
|
export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||||
const quoteRef = useRef(null);
|
const quoteRef = useRef(null);
|
||||||
const quote = calculateQuote(pricing, selections);
|
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 handleDownloadPDF = async () => {
|
||||||
const html2canvas = (await import('html2canvas')).default;
|
const html2canvas = (await import('html2canvas')).default;
|
||||||
@ -40,7 +43,8 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
|||||||
heightLeft -= pageHeight - margin;
|
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 (
|
return (
|
||||||
@ -89,12 +93,31 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{operator && (
|
{operator && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
<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">
|
<div className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
|
||||||
Selected Operator
|
Selected Operator
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold text-gray-900">{operator.name}</div>
|
<div className="font-semibold text-gray-900">
|
||||||
<div className="text-sm text-gray-500">Model: {operator.model}</div>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -152,9 +175,9 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-gray-300 pt-4 space-y-2">
|
<div className="border-t border-gray-300 pt-4">
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-lg font-bold text-gray-900">
|
||||||
<span>Subtotal</span>
|
<span>Total Cost</span>
|
||||||
<span>
|
<span>
|
||||||
{pricing.currency.symbol}
|
{pricing.currency.symbol}
|
||||||
{quote.subtotal.toLocaleString(pricing.currency.locale, {
|
{quote.subtotal.toLocaleString(pricing.currency.locale, {
|
||||||
@ -163,26 +186,6 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-gray-400 text-center border-t border-gray-200 pt-4">
|
<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({
|
export default function StepAccessControl({
|
||||||
accessControl,
|
accessControl,
|
||||||
|
remoteButtonOptions,
|
||||||
selected,
|
selected,
|
||||||
|
remoteButtons,
|
||||||
|
remoteQuantity,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onSetRemoteButtons,
|
||||||
|
onSetRemoteQuantity,
|
||||||
onBack,
|
onBack,
|
||||||
onNext,
|
onNext,
|
||||||
}) {
|
}) {
|
||||||
@ -29,27 +34,38 @@ export default function StepAccessControl({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
{accessControl.map((ac) => {
|
{accessControl.map((ac) => {
|
||||||
const isSelected = selected.includes(ac.id);
|
const isSelected = selected.includes(ac.id);
|
||||||
|
const isRemote = ac.id === 'remote-kit';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={ac.id}
|
key={ac.id}
|
||||||
onClick={() => onToggle(ac.id)}
|
className={`rounded-xl border-2 transition-all ${
|
||||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
|
||||||
isSelected
|
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'
|
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
<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="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold text-gray-900">{ac.name}</div>
|
<div className="font-semibold text-gray-900">{ac.name}</div>
|
||||||
|
{ac.model && (
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Model: {ac.model}
|
Model: {ac.model}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-sm text-gray-500 mt-1">
|
<div className="text-sm text-gray-500 mt-1">
|
||||||
{ac.description}
|
{ac.description}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||||
C${ac.price.toLocaleString('en-CA')}
|
{isRemote ? (
|
||||||
|
'Varies by buttons'
|
||||||
|
) : (
|
||||||
|
'C$' + ac.price.toLocaleString('en-CA')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -67,6 +83,80 @@ export default function StepAccessControl({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
export default function StepGroundLoops({
|
export default function StepGroundLoops({
|
||||||
|
detectors,
|
||||||
styles,
|
styles,
|
||||||
|
sizes,
|
||||||
types,
|
types,
|
||||||
value,
|
value,
|
||||||
onSetNeeded,
|
onSetNeeded,
|
||||||
onSetStyle,
|
onSetStyle,
|
||||||
onToggleType,
|
onToggleType,
|
||||||
|
onSetSize,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) {
|
}) {
|
||||||
const { needed, style, types: selectedTypes } = value;
|
const { needed, style, types: selectedTypes, typeSizes } = value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -118,15 +121,19 @@ export default function StepGroundLoops({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{types.map((t) => {
|
{types.map((t) => {
|
||||||
const isSelected = selectedTypes.includes(t.id);
|
const isSelected = selectedTypes.includes(t.id);
|
||||||
|
const currentSize = typeSizes[t.id];
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => onToggleType(t.id)}
|
className={`rounded-xl border-2 transition-all ${
|
||||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
|
||||||
isSelected
|
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'
|
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleType(t.id)}
|
||||||
|
className={`text-left w-full p-4 ${isSelected ? 'pb-3' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -155,6 +162,54 @@ export default function StepGroundLoops({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,36 +1,10 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
const categoryLabels = {
|
const categoryLabels = {
|
||||||
swing: 'Swing Gate Operators',
|
swing: 'Swing Gate Operators',
|
||||||
slide: 'Slide Gate Operators',
|
slide: 'Slide Gate Operators',
|
||||||
barrier: 'Barrier Gate Operators',
|
barrier: 'Barrier Gate Operators',
|
||||||
};
|
tilt: 'Tilt 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>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StepOperator({ operators, selected, onSelect }) {
|
export default function StepOperator({ operators, selected, onSelect }) {
|
||||||
@ -52,26 +26,33 @@ export default function StepOperator({ operators, selected, onSelect }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{operators
|
{operators
|
||||||
.filter((o) => o.category === cat)
|
.filter((o) => o.category === cat && !o.isCustom)
|
||||||
.map((op) => (
|
.map((op) => {
|
||||||
|
const isSelected = selected === op.id;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={op.id}
|
key={op.id}
|
||||||
onClick={() => onSelect(op.id)}
|
onClick={() => onSelect(op.id)}
|
||||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
className={`text-left p-0 rounded-xl border-2 transition-all overflow-hidden ${
|
||||||
selected === op.id
|
isSelected
|
||||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex flex-col sm:flex-row">
|
||||||
<div
|
<div className="sm:w-44 shrink-0 bg-gray-50 flex items-center justify-center relative">
|
||||||
className={`shrink-0 p-2 rounded-lg ${
|
<img
|
||||||
selected === op.id ? 'bg-blue-100' : 'bg-gray-100'
|
src={'/images/operators/' + op.imageFile}
|
||||||
}`}
|
alt={op.name}
|
||||||
>
|
className="w-full h-44 sm:h-full object-cover"
|
||||||
{categoryIcons[cat]}
|
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>
|
||||||
<div className="min-w-0">
|
<div className="flex-1 p-5">
|
||||||
<div className="font-semibold text-gray-900">
|
<div className="font-semibold text-gray-900">
|
||||||
{op.name}
|
{op.name}
|
||||||
</div>
|
</div>
|
||||||
@ -83,7 +64,7 @@ export default function StepOperator({ operators, selected, onSelect }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="text-lg font-bold text-blue-600">
|
<span className="text-lg font-bold text-blue-600">
|
||||||
C${op.basePrice.toLocaleString('en-CA')}
|
C{op.basePrice.toLocaleString('en-CA')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">base</span>
|
<span className="text-xs text-gray-400">base</span>
|
||||||
</div>
|
</div>
|
||||||
@ -95,10 +76,136 @@ export default function StepOperator({ operators, selected, onSelect }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{cat === 'tilt' && <TiltOperatorCard selected={selected} onSelect={onSelect} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,
|
needed: false,
|
||||||
style: null,
|
style: null,
|
||||||
types: [],
|
types: [],
|
||||||
|
typeSizes: {},
|
||||||
},
|
},
|
||||||
accessControl: [],
|
accessControl: [],
|
||||||
|
remoteButtons: 4,
|
||||||
|
remoteQuantity: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function reducer(state, action) {
|
function reducer(state, action) {
|
||||||
@ -22,7 +25,7 @@ function reducer(state, action) {
|
|||||||
case 'SET_GROUND_LOOPS_NEEDED':
|
case 'SET_GROUND_LOOPS_NEEDED':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
groundLoops: { needed: action.payload, style: null, types: [] },
|
groundLoops: { needed: action.payload, style: null, types: [], typeSizes: {} },
|
||||||
step: action.payload ? state.step : state.step + 1,
|
step: action.payload ? state.step : state.step + 1,
|
||||||
};
|
};
|
||||||
case 'SET_GROUND_LOOP_STYLE':
|
case 'SET_GROUND_LOOP_STYLE':
|
||||||
@ -33,6 +36,12 @@ function reducer(state, action) {
|
|||||||
case 'TOGGLE_GROUND_LOOP_TYPE': {
|
case 'TOGGLE_GROUND_LOOP_TYPE': {
|
||||||
const id = action.payload;
|
const id = action.payload;
|
||||||
const exists = state.groundLoops.types.includes(id);
|
const exists = state.groundLoops.types.includes(id);
|
||||||
|
const newTypeSizes = { ...state.groundLoops.typeSizes };
|
||||||
|
if (!exists) {
|
||||||
|
newTypeSizes[id] = '4x8';
|
||||||
|
} else {
|
||||||
|
delete newTypeSizes[id];
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
groundLoops: {
|
groundLoops: {
|
||||||
@ -40,6 +49,7 @@ function reducer(state, action) {
|
|||||||
types: exists
|
types: exists
|
||||||
? state.groundLoops.types.filter((t) => t !== id)
|
? state.groundLoops.types.filter((t) => t !== id)
|
||||||
: [...state.groundLoops.types, id],
|
: [...state.groundLoops.types, id],
|
||||||
|
typeSizes: newTypeSizes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -51,12 +61,28 @@ function reducer(state, action) {
|
|||||||
accessControl: exists
|
accessControl: exists
|
||||||
? state.accessControl.filter((a) => a !== id)
|
? state.accessControl.filter((a) => a !== id)
|
||||||
: [...state.accessControl, 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':
|
case 'NEXT_STEP':
|
||||||
return { ...state, step: Math.min(state.step + 1, 4) };
|
return { ...state, step: Math.min(state.step + 1, 4) };
|
||||||
case 'PREV_STEP':
|
case 'PREV_STEP':
|
||||||
return { ...state, step: Math.max(state.step - 1, 1) };
|
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':
|
case 'GO_TO_STEP':
|
||||||
return { ...state, step: Math.min(action.payload, 4) };
|
return { ...state, step: Math.min(action.payload, 4) };
|
||||||
case 'RESET':
|
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 [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const { step, operator, groundLoops, accessControl } = state;
|
const { step, operator, groundLoops, accessControl, remoteButtons, remoteQuantity } = state;
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ num: 1, label: 'Operator' },
|
{ num: 1, label: 'Operator' },
|
||||||
@ -79,7 +105,17 @@ export default function Wizard({ pricing }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
<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">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
Gate Operator Quotation System
|
Gate Operator Quotation System
|
||||||
</h1>
|
</h1>
|
||||||
@ -141,12 +177,15 @@ export default function Wizard({ pricing }) {
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<StepGroundLoops
|
<StepGroundLoops
|
||||||
|
detectors={pricing.loopDetectors}
|
||||||
styles={pricing.groundLoopStyles}
|
styles={pricing.groundLoopStyles}
|
||||||
|
sizes={pricing.groundLoopSizes}
|
||||||
types={pricing.groundLoopTypes}
|
types={pricing.groundLoopTypes}
|
||||||
value={groundLoops}
|
value={groundLoops}
|
||||||
onSetNeeded={(needed) => dispatch({ type: 'SET_GROUND_LOOPS_NEEDED', payload: needed })}
|
onSetNeeded={(needed) => dispatch({ type: 'SET_GROUND_LOOPS_NEEDED', payload: needed })}
|
||||||
onSetStyle={(id) => dispatch({ type: 'SET_GROUND_LOOP_STYLE', payload: id })}
|
onSetStyle={(id) => dispatch({ type: 'SET_GROUND_LOOP_STYLE', payload: id })}
|
||||||
onToggleType={(id) => dispatch({ type: 'TOGGLE_GROUND_LOOP_TYPE', 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' })}
|
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||||
/>
|
/>
|
||||||
@ -155,8 +194,13 @@ export default function Wizard({ pricing }) {
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<StepAccessControl
|
<StepAccessControl
|
||||||
accessControl={pricing.accessControl}
|
accessControl={pricing.accessControl}
|
||||||
|
remoteButtonOptions={pricing.remoteButtonOptions}
|
||||||
selected={accessControl}
|
selected={accessControl}
|
||||||
|
remoteButtons={remoteButtons}
|
||||||
|
remoteQuantity={remoteQuantity}
|
||||||
onToggle={(id) => dispatch({ type: 'TOGGLE_ACCESS_CONTROL', payload: id })}
|
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' })}
|
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||||
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||||
/>
|
/>
|
||||||
@ -165,7 +209,7 @@ export default function Wizard({ pricing }) {
|
|||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<QuoteSummary
|
<QuoteSummary
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
selections={{ operator, groundLoops, accessControl }}
|
selections={{ operator, groundLoops, accessControl, remoteButtons, remoteQuantity }}
|
||||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||||
onNew={() => dispatch({ type: 'RESET' })}
|
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,6 +4,20 @@ export function calculateQuote(pricing, selections) {
|
|||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
|
|
||||||
if (operator) {
|
if (operator) {
|
||||||
|
// 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);
|
const op = pricing.operators.find((o) => o.id === operator);
|
||||||
if (op) {
|
if (op) {
|
||||||
op.requiredParts.forEach((part) => {
|
op.requiredParts.forEach((part) => {
|
||||||
@ -20,6 +34,7 @@ export function calculateQuote(pricing, selections) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (groundLoops?.needed) {
|
if (groundLoops?.needed) {
|
||||||
if (groundLoops.style) {
|
if (groundLoops.style) {
|
||||||
@ -28,7 +43,7 @@ export function calculateQuote(pricing, selections) {
|
|||||||
items.push({
|
items.push({
|
||||||
type: 'groundLoopStyle',
|
type: 'groundLoopStyle',
|
||||||
category: 'Ground Loops',
|
category: 'Ground Loops',
|
||||||
name: `Installation: ${gls.name}`,
|
name: 'Installation: ' + gls.name,
|
||||||
qty: 1,
|
qty: 1,
|
||||||
unitPrice: gls.additionalCost,
|
unitPrice: gls.additionalCost,
|
||||||
lineTotal: gls.additionalCost,
|
lineTotal: gls.additionalCost,
|
||||||
@ -38,18 +53,40 @@ export function calculateQuote(pricing, selections) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (groundLoops.types && groundLoops.types.length > 0) {
|
if (groundLoops.types && groundLoops.types.length > 0) {
|
||||||
|
// Default detector pricing
|
||||||
|
const detectorPrice = pricing.loopDetectors?.[0]?.price ?? 250;
|
||||||
|
|
||||||
groundLoops.types.forEach((typeId) => {
|
groundLoops.types.forEach((typeId) => {
|
||||||
const glt = pricing.groundLoopTypes.find((t) => t.id === typeId);
|
const glt = pricing.groundLoopTypes.find((t) => t.id === typeId);
|
||||||
if (glt) {
|
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({
|
items.push({
|
||||||
type: 'groundLoopType',
|
type: 'groundLoopType',
|
||||||
category: 'Ground Loops',
|
category: 'Ground Loops',
|
||||||
name: glt.name,
|
name: glt.name + (size ? ' (' + size.name + ')' : ''),
|
||||||
qty: 1,
|
qty: 1,
|
||||||
unitPrice: glt.price,
|
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,6 +94,23 @@ export function calculateQuote(pricing, selections) {
|
|||||||
|
|
||||||
if (accessControl && accessControl.length > 0) {
|
if (accessControl && accessControl.length > 0) {
|
||||||
accessControl.forEach((acId) => {
|
accessControl.forEach((acId) => {
|
||||||
|
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);
|
const ac = pricing.accessControl.find((a) => a.id === acId);
|
||||||
if (ac) {
|
if (ac) {
|
||||||
items.push({
|
items.push({
|
||||||
@ -69,12 +123,9 @@ export function calculateQuote(pricing, selections) {
|
|||||||
});
|
});
|
||||||
subtotal += ac.price;
|
subtotal += ac.price;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const taxRate = 0.13;
|
return { items, subtotal, total: subtotal };
|
||||||
const tax = subtotal * taxRate;
|
|
||||||
const total = subtotal + tax;
|
|
||||||
|
|
||||||
return { items, subtotal, tax, taxRate, total };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,112 +8,137 @@
|
|||||||
{
|
{
|
||||||
"id": "swing-residential",
|
"id": "swing-residential",
|
||||||
"name": "Residential Swing Gate Operator",
|
"name": "Residential Swing Gate Operator",
|
||||||
"model": "SG-1000",
|
"model": "LA500",
|
||||||
"category": "swing",
|
"category": "swing",
|
||||||
"description": "12V DC swing gate operator for residential gates up to 14 ft",
|
"description": "24V DC Swing Gate Linear Operator ",
|
||||||
"basePrice": 2495,
|
"basePrice": 2495,
|
||||||
"image": "swing",
|
"image": "swing",
|
||||||
|
"imageFile": "liftmaster-la500-bundle.jpg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-swing", "name": "Operator Unit (SG-1000)", "qty": 2, "unitPrice": 1247.50 },
|
{ "id": "op-unit-swing", "name": "Operator Unit (LA500)", "qty": 2, "unitPrice": 1247.50 },
|
||||||
{ "id": "bracket-kit", "name": "Gate Bracket Kit", "qty": 2, "unitPrice": 85 },
|
{ "id": "mounting-post", "name": "Mounting Post", "qty": 1, "unitPrice": 85 },
|
||||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
{ "id": "prep-cost", "name": "In House Prep Cost", "qty": 1, "unitPrice": 150 },
|
||||||
{ "id": "hinges", "name": "Gate Hinges (Heavy Duty)", "qty": 4, "unitPrice": 45 }
|
{ "id": "shipping", "name": "Shipping Cost", "qty": 1, "unitPrice": 200 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "swing-commercial",
|
"id": "swing-commercial",
|
||||||
"name": "Commercial Swing Gate Operator",
|
"name": "Commercial Swing Gate Operator",
|
||||||
"model": "SG-2000",
|
"model": "CSW24UL",
|
||||||
"category": "swing",
|
"category": "swing",
|
||||||
"description": "24V DC heavy-duty swing operator for commercial gates up to 24 ft",
|
"description": "24V DC Heavy Duty Swing Gate Operator",
|
||||||
"basePrice": 3995,
|
"basePrice": 3995,
|
||||||
"image": "swing",
|
"image": "swing",
|
||||||
|
"imageFile": "liftmaster-csw24.jpg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-swing-hd", "name": "Heavy Duty Operator Unit (SG-2000)", "qty": 2, "unitPrice": 1997.50 },
|
{ "id": "op-unit-swing-hd", "name": "Heavy Duty Operator Unit (CSW24UL)", "qty": 2, "unitPrice": 1997.50 },
|
||||||
{ "id": "bracket-kit-hd", "name": "Commercial Bracket Kit", "qty": 2, "unitPrice": 145 },
|
{ "id": "mount-pad", "name": "Mounting Pad", "qty": 2, "unitPrice": 145 },
|
||||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
{ "id": "prep-cost", "name": "In House Prep Cost", "qty": 1, "unitPrice": 150 },
|
||||||
{ "id": "hinges-hd", "name": "Commercial Gate Hinges", "qty": 4, "unitPrice": 75 },
|
{ "id": "shipping", "name": "Shipping Cost", "qty": 1, "unitPrice": 200 },
|
||||||
{ "id": "warning-light", "name": "Strobe Warning Light", "qty": 1, "unitPrice": 195 }
|
{ "id": "mounting-post", "name": "Mounting Post", "qty": 2, "unitPrice": 85 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "slide-residential",
|
"id": "slide-residential",
|
||||||
"name": "Light Commercial Slide Gate Operator",
|
"name": "Light Commercial Slide Gate Operator",
|
||||||
"model": "SL-1500",
|
"model": "CSL24UL",
|
||||||
"category": "slide",
|
"category": "slide",
|
||||||
"description": "12V DC slide operator for residential and light commercial gates up to 20 ft",
|
"description": "24V DC Light Duty Slide Gate Operator",
|
||||||
"basePrice": 2495,
|
"basePrice": 2495,
|
||||||
"image": "slide",
|
"image": "slide",
|
||||||
|
"imageFile": "liftmaster-csl24.jpg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-slide-lc", "name": "Slide Operator Unit (SL-1500)", "qty": 1, "unitPrice": 2495 },
|
{ "id": "op-unit-slide-lc", "name": "Slide Operator Unit (CSL24UL)", "qty": 1, "unitPrice": 2495 },
|
||||||
{ "id": "rack-kit-lc", "name": "Slide Gate Rack Kit (10ft sections)", "qty": 2, "unitPrice": 175 },
|
{ "id": "mount-pad", "name": "Mounting Pad", "qty": 2, "unitPrice": 145 },
|
||||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 }
|
{ "id": "prep-cost", "name": "In House Prep Cost", "qty": 1, "unitPrice": 150 },
|
||||||
|
{ "id": "shipping", "name": "Shipping Cost", "qty": 1, "unitPrice": 200 },
|
||||||
|
{ "id": "mounting-post", "name": "Mounting Post", "qty": 2, "unitPrice": 85 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "slide-commercial",
|
"id": "slide-commercial",
|
||||||
"name": "Commercial Slide Gate Operator",
|
"name": "Commercial Slide Gate Operator",
|
||||||
"model": "SL-3000",
|
"model": "IHSL24UL",
|
||||||
"category": "slide",
|
"category": "slide",
|
||||||
"description": "24V DC slide gate operator for commercial gates up to 40 ft",
|
"description": "24V DC Commercial Gate Operator",
|
||||||
"basePrice": 3895,
|
"basePrice": 3895,
|
||||||
"image": "slide",
|
"image": "slide",
|
||||||
|
"imageFile": "liftmaster-ihsl24ul.jpg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-slide", "name": "Heavy Duty Operator Unit (SL-3000)", "qty": 1, "unitPrice": 3895 },
|
{ "id": "op-unit-slide", "name": "Slide Operator Unit (IHSL24UL)", "qty": 1, "unitPrice": 3895 },
|
||||||
{ "id": "rack-kit", "name": "Slide Gate Rack Kit (10ft sections)", "qty": 4, "unitPrice": 175 },
|
{ "id": "prep-cost", "name": "In House Prep Cost", "qty": 1, "unitPrice": 150 },
|
||||||
{ "id": "photo-eye", "name": "Photo Eye Kit", "qty": 1, "unitPrice": 125 },
|
{ "id": "shipping", "name": "Shipping Cost", "qty": 1, "unitPrice": 200 },
|
||||||
{ "id": "warning-light", "name": "Strobe Warning Light", "qty": 1, "unitPrice": 195 },
|
{ "id": "mounting-post", "name": "Mounting Post", "qty": 2, "unitPrice": 85 }
|
||||||
{ "id": "sensor-loop", "name": "Vehicle Loop Sensor", "qty": 1, "unitPrice": 285 }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "barrier-parking",
|
"id": "barrier-parking",
|
||||||
"name": "Parking Barrier Gate Operator",
|
"name": "Barrier Gate Operator",
|
||||||
"model": "BG-200",
|
"model": "MAT",
|
||||||
"category": "barrier",
|
"category": "barrier",
|
||||||
"description": "120V AC barrier gate for parking lots and access control up to 20 ft",
|
"description": "Mega Arm Tower Barrier Gate Operator",
|
||||||
"basePrice": 3195,
|
"basePrice": 3195,
|
||||||
"image": "barrier",
|
"image": "barrier",
|
||||||
|
"imageFile": "Mat.jpeg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-barrier", "name": "Barrier Arm Assembly (BG-200)", "qty": 1, "unitPrice": 3195 },
|
{ "id": "op-unit-barrier", "name": "Mega Arm Tower Barrier Gate Operator(MAT)", "qty": 1, "unitPrice": 3195 },
|
||||||
{ "id": "barrier-arm", "name": "Barrier Arm (8ft Aluminum)", "qty": 1, "unitPrice": 395 },
|
{ "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",
|
"id": "barrier-heavy",
|
||||||
"name": "Heavy Duty Barrier Gate Operator",
|
"name": "Techna Barrier Gate Operator",
|
||||||
"model": "BG-500",
|
"model": "CBG24DC",
|
||||||
"category": "barrier",
|
"category": "barrier",
|
||||||
"description": "230V AC heavy-duty barrier gate for industrial sites up to 30 ft",
|
"description": "Commercial Barrier Gate operator",
|
||||||
"basePrice": 5495,
|
"basePrice": 5495,
|
||||||
"image": "barrier",
|
"image": "barrier",
|
||||||
|
"imageFile": "techno.jpeg",
|
||||||
"requiredParts": [
|
"requiredParts": [
|
||||||
{ "id": "op-unit-barrier-hd", "name": "Heavy Duty Barrier Assembly (BG-500)", "qty": 1, "unitPrice": 5495 },
|
{ "id": "op-unit-barrier-hd", "name": "Techna Barrier Gate Operator (CBG24DC)", "qty": 1, "unitPrice": 5495 },
|
||||||
{ "id": "barrier-arm-hd", "name": "Barrier Arm (16ft Steel)", "qty": 1, "unitPrice": 695 },
|
{ "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 }
|
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tilt",
|
||||||
|
"name": "Tilt Gate Operator",
|
||||||
|
"model": "",
|
||||||
|
"category": "tilt",
|
||||||
|
"description": "Enter tilt gate operator details below",
|
||||||
|
"basePrice": 0,
|
||||||
|
"image": "tilt",
|
||||||
|
"imageFile": "",
|
||||||
|
"isCustom": true,
|
||||||
|
"requiredParts": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"groundLoopStyles": [
|
"groundLoopStyles": [
|
||||||
{ "id": "saw-cut", "name": "Saw-Cut Installation", "description": "Loop cut into existing pavement and sealed flush", "additionalCost": 0 },
|
{ "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 }
|
{ "id": "pave-over", "name": "Pave-Over Installation", "description": "Surface-mount loop installed on top of pavement with protective casing", "additionalCost": 195 }
|
||||||
],
|
],
|
||||||
|
"loopDetectors": [
|
||||||
|
{ "id": "single", "name": "Single-Channel Loop Detector", "description": "Detects vehicle presence over the loop wire", "price": 250 }
|
||||||
|
],
|
||||||
|
"groundLoopSizes": [
|
||||||
|
{ "id": "4x8", "name": "4' x 8'", "description": "Standard loop size for residential applications", "additionalCost": 0 },
|
||||||
|
{ "id": "6x12", "name": "6' x 12'", "description": "Larger loop size for commercial or wide driveways", "additionalCost": 175 }
|
||||||
|
],
|
||||||
"groundLoopTypes": [
|
"groundLoopTypes": [
|
||||||
{ "id": "interrupt", "name": "Interrupt Loop", "description": "Primary vehicle detection loop for gate activation", "price": 425 },
|
{ "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": "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 }
|
{ "id": "exit", "name": "Exit Loop", "description": "Free exit loop on departure side for automatic exit detection", "price": 375 }
|
||||||
],
|
],
|
||||||
|
"remoteButtonOptions": [
|
||||||
|
{ "id": "1", "name": "1-Button Remote", "price": 95 },
|
||||||
|
{ "id": "2", "name": "2-Button Remote", "price": 125 },
|
||||||
|
{ "id": "3", "name": "3-Button Remote", "price": 165 },
|
||||||
|
{ "id": "4", "name": "4-Button Remote", "price": 195 }
|
||||||
|
],
|
||||||
"accessControl": [
|
"accessControl": [
|
||||||
{ "id": "keypad", "name": "Digital Keypad", "model": "KP-200", "description": "Weatherproof digital keypad with backlit keys, 500 user codes", "price": 295 },
|
{ "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": "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": "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": "remote-kit", "name": "Remote Control", "model": "", "description": "Remote controls with rolling code technology", "price": 0 },
|
||||||
{ "id": "solar-kit", "name": "Solar Panel Kit", "model": "SP-100", "description": "100W solar panel with charge controller and battery", "price": 695 },
|
{ "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 }
|
{ "id": "gsm-controller", "name": "GSM Cellular Controller", "model": "GSM-4G", "description": "4G cellular controller with app, no phone line needed", "price": 595 }
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,35 +1,98 @@
|
|||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const session = require('express-session');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors({
|
||||||
|
origin: process.env.NODE_ENV === 'production' ? false : 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.APP_SESSION_SECRET || 'dev-secret-change-in-production',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const pricingData = JSON.parse(
|
const pricingData = JSON.parse(
|
||||||
fs.readFileSync(path.join(__dirname, 'data', 'pricing.json'), 'utf-8')
|
fs.readFileSync(path.join(__dirname, 'data', 'pricing.json'), 'utf-8')
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/pricing', (req, res) => {
|
// Auth middleware
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.session && req.session.authenticated) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (password === process.env.APP_PASSWORD) {
|
||||||
|
req.session.authenticated = true;
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Invalid password' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Failed to logout' });
|
||||||
|
}
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check auth status
|
||||||
|
app.get('/api/auth/status', (req, res) => {
|
||||||
|
res.json({ authenticated: !!(req.session && req.session.authenticated) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected API routes
|
||||||
|
app.get('/api/pricing', requireAuth, (req, res) => {
|
||||||
res.json(pricingData);
|
res.json(pricingData);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/pricing/:category', (req, res) => {
|
app.get('/api/pricing/:category', requireAuth, (req, res) => {
|
||||||
const { category } = req.params;
|
const { category } = req.params;
|
||||||
const validCategories = ['operators', 'groundLoopStyles', 'groundLoopTypes', 'accessControl'];
|
const validCategories = ['operators', 'groundLoopStyles', 'groundLoopSizes', 'loopDetectors', 'groundLoopTypes', 'accessControl', 'remoteButtonOptions'];
|
||||||
if (validCategories.includes(category)) {
|
if (validCategories.includes(category)) {
|
||||||
return res.json(pricingData[category]);
|
return res.json(pricingData[category]);
|
||||||
}
|
}
|
||||||
res.status(400).json({ error: `Invalid category. Use: ${validCategories.join(', ')}` });
|
res.status(400).json({ error: `Invalid category. Use: ${validCategories.join(', ')}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve static files with auth protection
|
||||||
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
||||||
if (fs.existsSync(clientDist)) {
|
if (fs.existsSync(clientDist)) {
|
||||||
app.use(express.static(clientDist));
|
// Allow unauthenticated access to login page assets and auth endpoints
|
||||||
app.get('*', (req, res) => {
|
app.use('/assets', express.static(path.join(clientDist, 'assets')));
|
||||||
|
|
||||||
|
// Login page is always accessible
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
res.sendFile(path.join(clientDist, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// All other routes require auth
|
||||||
|
app.get('*', requireAuth, (req, res) => {
|
||||||
res.sendFile(path.join(clientDist, 'index.html'));
|
res.sendFile(path.join(clientDist, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -47,5 +110,6 @@ function getLocalIP() {
|
|||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
const ip = getLocalIP();
|
const ip = getLocalIP();
|
||||||
console.log(`\n Local: http://localhost:${PORT}`);
|
console.log(`\n Local: http://localhost:${PORT}`);
|
||||||
console.log(` Network: http://${ip}:${PORT}\n`);
|
console.log(` Network: http://${ip}:${PORT}`);
|
||||||
|
console.log(` Login: http://localhost:${PORT}/login\n`);
|
||||||
});
|
});
|
||||||
|
|||||||
69
server/package-lock.json
generated
@ -9,7 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2"
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@ -174,6 +176,18 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -294,6 +308,29 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cookie-signature": "~1.0.7",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "~5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@ -576,6 +613,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@ -619,6 +665,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@ -823,6 +878,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2"
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||