This commit is contained in:
Todd
2026-05-24 13:30:30 -04:00
parent 753bc04141
commit f29035ce81
26 changed files with 1092 additions and 385 deletions

3
.gitignore vendored
View File

@ -2,3 +2,6 @@ node_modules/
dist/
.DS_Store
*.log
# Environment variables
.env

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

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

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

View File

@ -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">

View File

@ -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'
}`}
>
&minus;
</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>

View File

@ -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>

View File

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

View File

@ -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' })}
/>

View File

@ -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 }
]
}

View File

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

View File

@ -8,112 +8,137 @@
{
"id": "swing-residential",
"name": "Residential Swing Gate Operator",
"model": "SG-1000",
"model": "LA500",
"category": "swing",
"description": "12V DC swing gate operator for residential gates up to 14 ft",
"description": "24V DC Swing Gate Linear Operator ",
"basePrice": 2495,
"image": "swing",
"imageFile": "liftmaster-la500-bundle.jpg",
"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": "op-unit-swing", "name": "Operator Unit (LA500)", "qty": 2, "unitPrice": 1247.50 },
{ "id": "mounting-post", "name": "Mounting Post", "qty": 1, "unitPrice": 85 },
{ "id": "prep-cost", "name": "In House Prep Cost", "qty": 1, "unitPrice": 150 },
{ "id": "shipping", "name": "Shipping Cost", "qty": 1, "unitPrice": 200 }
]
},
{
"id": "swing-commercial",
"name": "Commercial Swing Gate Operator",
"model": "SG-2000",
"model": "CSW24UL",
"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,
"image": "swing",
"imageFile": "liftmaster-csw24.jpg",
"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": "op-unit-swing-hd", "name": "Heavy Duty Operator Unit (CSW24UL)", "qty": 2, "unitPrice": 1997.50 },
{ "id": "mount-pad", "name": "Mounting Pad", "qty": 2, "unitPrice": 145 },
{ "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-residential",
"name": "Light Commercial Slide Gate Operator",
"model": "SL-1500",
"model": "CSL24UL",
"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,
"image": "slide",
"imageFile": "liftmaster-csl24.jpg",
"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": "op-unit-slide-lc", "name": "Slide Operator Unit (CSL24UL)", "qty": 1, "unitPrice": 2495 },
{ "id": "mount-pad", "name": "Mounting Pad", "qty": 2, "unitPrice": 145 },
{ "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",
"name": "Commercial Slide Gate Operator",
"model": "SL-3000",
"model": "IHSL24UL",
"category": "slide",
"description": "24V DC slide gate operator for commercial gates up to 40 ft",
"description": "24V DC Commercial Gate Operator",
"basePrice": 3895,
"image": "slide",
"imageFile": "liftmaster-ihsl24ul.jpg",
"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": "op-unit-slide", "name": "Slide Operator Unit (IHSL24UL)", "qty": 1, "unitPrice": 3895 },
{ "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": "barrier-parking",
"name": "Parking Barrier Gate Operator",
"model": "BG-200",
"name": "Barrier Gate Operator",
"model": "MAT",
"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,
"image": "barrier",
"imageFile": "Mat.jpeg",
"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": "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-heavy",
"name": "Heavy Duty Barrier Gate Operator",
"model": "BG-500",
"name": "Techna Barrier Gate Operator",
"model": "CBG24DC",
"category": "barrier",
"description": "230V AC heavy-duty barrier gate for industrial sites up to 30 ft",
"description": "Commercial Barrier Gate operator",
"basePrice": 5495,
"image": "barrier",
"imageFile": "techno.jpeg",
"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 }
{ "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": "tilt",
"name": "Tilt Gate Operator",
"model": "",
"category": "tilt",
"description": "Enter tilt gate operator details below",
"basePrice": 0,
"image": "tilt",
"imageFile": "",
"isCustom": true,
"requiredParts": []
}
],
"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 }
],
"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": [
{ "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 }
],
"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": [
{ "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": "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": "gsm-controller", "name": "GSM Cellular Controller", "model": "GSM-4G", "description": "4G cellular controller with app, no phone line needed", "price": 595 }
]

View File

@ -1,35 +1,98 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const os = require('os');
const session = require('express-session');
const app = express();
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(
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);
});
app.get('/api/pricing/:category', (req, res) => {
app.get('/api/pricing/:category', requireAuth, (req, res) => {
const { category } = req.params;
const validCategories = ['operators', 'groundLoopStyles', 'groundLoopTypes', 'accessControl'];
const validCategories = ['operators', 'groundLoopStyles', 'groundLoopSizes', 'loopDetectors', 'groundLoopTypes', 'accessControl', 'remoteButtonOptions'];
if (validCategories.includes(category)) {
return res.json(pricingData[category]);
}
res.status(400).json({ error: `Invalid category. Use: ${validCategories.join(', ')}` });
});
// Serve static files with auth protection
const clientDist = path.join(__dirname, '..', 'client', 'dist');
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get('*', (req, res) => {
// Allow unauthenticated access to login page assets and auth endpoints
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'));
});
}
@ -47,5 +110,6 @@ function getLocalIP() {
app.listen(PORT, '0.0.0.0', () => {
const ip = getLocalIP();
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`);
});

View File

@ -9,7 +9,9 @@
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2"
"dotenv": "^17.4.2",
"express": "^4.18.2",
"express-session": "^1.19.0"
}
},
"node_modules/accepts": {
@ -174,6 +176,18 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -294,6 +308,29 @@
"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": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -576,6 +613,15 @@
"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": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -619,6 +665,15 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -823,6 +878,18 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@ -8,6 +8,8 @@
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2"
"dotenv": "^17.4.2",
"express": "^4.18.2",
"express-session": "^1.19.0"
}
}