-
- {t.name}
+
diff --git a/client/src/components/StepOperator.jsx b/client/src/components/StepOperator.jsx
index b99cd91..8bc0246 100644
--- a/client/src/components/StepOperator.jsx
+++ b/client/src/components/StepOperator.jsx
@@ -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: (
-
-
-
-
-
-
-
- ),
- slide: (
-
-
-
-
-
-
-
- ),
- barrier: (
-
-
-
-
-
-
- ),
+ tilt: 'Tilt Gate Operators',
};
export default function StepOperator({ operators, selected, onSelect }) {
@@ -52,53 +26,186 @@ export default function StepOperator({ operators, selected, onSelect }) {
{operators
- .filter((o) => o.category === cat)
- .map((op) => (
-
);
}
+
+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 (
+
+
+
+
Custom Tilt Gate Operator
+ {isTiltSelected && (
+
+ Selected
+
+ )}
+
+
+
+
+
+
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 &&
{errors.model}
}
+
+
+
+
+
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 &&
{errors.description}
}
+
+
+
+
+
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 &&
{errors.price}
}
+
+
+
+
+ {isTiltSelected ? (
+
+ Deselect
+
+ ) : (
+
+ Select Custom Tilt Operator
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/Wizard.jsx b/client/src/components/Wizard.jsx
index 7457735..8a2fea0 100644
--- a/client/src/components/Wizard.jsx
+++ b/client/src/components/Wizard.jsx
@@ -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 (
-
+
+
+
+
+
+ Logout
+
Gate Operator Quotation System
@@ -141,12 +177,15 @@ export default function Wizard({ pricing }) {
{step === 2 && (
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 && (
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 && (
dispatch({ type: 'PREV_STEP' })}
onNew={() => dispatch({ type: 'RESET' })}
/>
diff --git a/client/src/data/pricing.json b/client/src/data/pricing.json
deleted file mode 100644
index c0dbc8a..0000000
--- a/client/src/data/pricing.json
+++ /dev/null
@@ -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 }
- ]
-}
diff --git a/client/src/utils/quoteCalculator.js b/client/src/utils/quoteCalculator.js
index 1d93bcc..071737c 100644
--- a/client/src/utils/quoteCalculator.js
+++ b/client/src/utils/quoteCalculator.js
@@ -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 };
}
diff --git a/server/data/pricing.json b/server/data/pricing.json
index c0dbc8a..6474a3f 100644
--- a/server/data/pricing.json
+++ b/server/data/pricing.json
@@ -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 }
]
diff --git a/server/index.js b/server/index.js
index 601fff4..0b29e5a 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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`);
});
diff --git a/server/package-lock.json b/server/package-lock.json
index 4fef64e..bf5982d 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -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",
diff --git a/server/package.json b/server/package.json
index 9ece317..6c12019 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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"
}
}