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

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