login
This commit is contained in:
101
client/src/components/Login.jsx
Normal file
101
client/src/components/Login.jsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
onLogin();
|
||||
} else {
|
||||
setError('Invalid password. Please try again.');
|
||||
setPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError('Connection error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Gate Quote
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2">
|
||||
Enter the access code to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Access Code
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter access code"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 text-lg text-center border-2 border-gray-200 rounded-xl placeholder-gray-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || loading}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-xl font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Unlock'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-xs text-gray-400 text-center">
|
||||
Gate Operator Quotation System
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,10 @@ import { calculateQuote } from '../utils/quoteCalculator';
|
||||
export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
const quoteRef = useRef(null);
|
||||
const quote = calculateQuote(pricing, selections);
|
||||
const operator = pricing.operators.find((o) => o.id === selections.operator);
|
||||
const operator =
|
||||
selections.operator && typeof selections.operator === 'object'
|
||||
? selections.operator
|
||||
: pricing.operators.find((o) => o.id === selections.operator);
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
@ -40,7 +43,8 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
heightLeft -= pageHeight - margin;
|
||||
}
|
||||
|
||||
pdf.save(`Gate_Quote_${operator?.model || 'Quote'}.pdf`);
|
||||
const modelName = operator?.tiltModel || operator?.model || 'Quote';
|
||||
pdf.save('Gate_Quote_' + modelName + '.pdf');
|
||||
};
|
||||
|
||||
return (
|
||||
@ -89,12 +93,31 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
</div>
|
||||
|
||||
{operator && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
|
||||
Selected Operator
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4 flex items-start gap-4">
|
||||
{operator.imageFile && !operator.tiltModel && (
|
||||
<img
|
||||
src={'/images/operators/' + operator.imageFile}
|
||||
alt={operator.name}
|
||||
className="w-24 h-24 rounded-lg object-cover shrink-0 border border-gray-200"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-1">
|
||||
Selected Operator
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{operator.tiltName || operator.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {operator.tiltModel || operator.model}
|
||||
</div>
|
||||
{operator.tiltDescription && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{operator.tiltDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900">{operator.name}</div>
|
||||
<div className="text-sm text-gray-500">Model: {operator.model}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -152,9 +175,9 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-300 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<div className="border-t border-gray-300 pt-4">
|
||||
<div className="flex justify-between text-lg font-bold text-gray-900">
|
||||
<span>Total Cost</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.subtotal.toLocaleString(pricing.currency.locale, {
|
||||
@ -163,26 +186,6 @@ export default function QuoteSummary({ pricing, selections, onBack, onNew }) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>HST ({((quote.taxRate ?? 0) * 100).toFixed(0)}%)</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.tax.toLocaleString(pricing.currency.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold text-gray-900 border-t border-gray-200 pt-2">
|
||||
<span>Total Estimate</span>
|
||||
<span>
|
||||
{pricing.currency.symbol}
|
||||
{quote.total.toLocaleString(pricing.currency.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-400 text-center border-t border-gray-200 pt-4">
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
export default function StepAccessControl({
|
||||
accessControl,
|
||||
remoteButtonOptions,
|
||||
selected,
|
||||
remoteButtons,
|
||||
remoteQuantity,
|
||||
onToggle,
|
||||
onSetRemoteButtons,
|
||||
onSetRemoteQuantity,
|
||||
onBack,
|
||||
onNext,
|
||||
}) {
|
||||
@ -29,44 +34,129 @@ export default function StepAccessControl({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{accessControl.map((ac) => {
|
||||
const isSelected = selected.includes(ac.id);
|
||||
const isRemote = ac.id === 'remote-kit';
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={ac.id}
|
||||
onClick={() => onToggle(ac.id)}
|
||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
||||
className={`rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">{ac.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {ac.model}
|
||||
<button
|
||||
onClick={() => onToggle(ac.id)}
|
||||
className={`text-left w-full p-4 ${isSelected && isRemote ? 'pb-3' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">{ac.name}</div>
|
||||
{ac.model && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Model: {ac.model}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{ac.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
{isRemote ? (
|
||||
'Varies by buttons'
|
||||
) : (
|
||||
'C$' + ac.price.toLocaleString('en-CA')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{ac.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${ac.price.toLocaleString('en-CA')}
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && isRemote && (
|
||||
<div className="px-4 pt-3 pb-4 border-t border-blue-200 mt-0 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Number of Buttons
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{remoteButtonOptions.map((opt) => {
|
||||
const isBtnSelected = remoteButtons === Number(opt.id);
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteButtons(Number(opt.id));
|
||||
}}
|
||||
className={`px-3 py-2 text-sm rounded-lg border-2 transition-all text-center ${
|
||||
isBtnSelected
|
||||
? 'border-blue-600 bg-white shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${isBtnSelected ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{opt.id}-Button
|
||||
</div>
|
||||
<div className={`text-xs mt-0.5 ${isBtnSelected ? 'text-blue-500' : 'text-gray-400'}`}>
|
||||
C${opt.price.toLocaleString('en-CA')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteQuantity(remoteQuantity - 1);
|
||||
}}
|
||||
disabled={remoteQuantity <= 1}
|
||||
className={`w-9 h-9 rounded-lg border-2 flex items-center justify-center text-lg font-medium transition-all ${
|
||||
remoteQuantity <= 1
|
||||
? 'border-gray-200 text-gray-300 cursor-not-allowed'
|
||||
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="w-14 h-9 rounded-lg border-2 border-gray-300 flex items-center justify-center text-sm font-semibold text-gray-900 bg-white">
|
||||
{remoteQuantity}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetRemoteQuantity(remoteQuantity + 1);
|
||||
}}
|
||||
className="w-9 h-9 rounded-lg border-2 border-gray-300 flex items-center justify-center text-lg font-medium text-gray-600 hover:border-gray-400 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
{remoteQuantity > 1 ? 'remotes' : 'remote'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
export default function StepGroundLoops({
|
||||
detectors,
|
||||
styles,
|
||||
sizes,
|
||||
types,
|
||||
value,
|
||||
onSetNeeded,
|
||||
onSetStyle,
|
||||
onToggleType,
|
||||
onSetSize,
|
||||
onNext,
|
||||
onBack,
|
||||
}) {
|
||||
const { needed, style, types: selectedTypes } = value;
|
||||
const { needed, style, types: selectedTypes, typeSizes } = value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -118,43 +121,95 @@ export default function StepGroundLoops({
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{types.map((t) => {
|
||||
const isSelected = selectedTypes.includes(t.id);
|
||||
const currentSize = typeSizes[t.id];
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => onToggleType(t.id)}
|
||||
className={`text-left p-4 rounded-xl border-2 transition-all ${
|
||||
className={`rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{t.name}
|
||||
<button
|
||||
onClick={() => onToggleType(t.id)}
|
||||
className={`text-left w-full p-4 ${isSelected ? 'pb-3' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{t.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${t.price.toLocaleString('en-CA')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{t.description}
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold text-blue-600">
|
||||
C${t.price.toLocaleString('en-CA')}
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center mt-1 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-600'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</button>
|
||||
|
||||
{isSelected && (
|
||||
<div className="px-4 pb-4 border-t border-blue-200 pt-3 mt-0 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-100 rounded-md px-3 py-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Includes 1 Loop Detector (+C${(detectors?.[0]?.price ?? 250).toLocaleString('en-CA')})</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">
|
||||
Loop Size
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{sizes.map((s) => {
|
||||
const isSizeSelected = currentSize === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetSize(t.id, s.id);
|
||||
}}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-lg border-2 transition-all ${
|
||||
isSizeSelected
|
||||
? 'border-blue-600 bg-white shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${isSizeSelected ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{s.name}
|
||||
</div>
|
||||
{s.additionalCost > 0 && (
|
||||
<div className={`text-xs mt-0.5 ${isSizeSelected ? 'text-blue-500' : 'text-gray-400'}`}>
|
||||
+C${s.additionalCost.toLocaleString('en-CA')}
|
||||
</div>
|
||||
)}
|
||||
{s.additionalCost === 0 && (
|
||||
<div className="text-xs mt-0.5 text-gray-400">Standard</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,36 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const categoryLabels = {
|
||||
swing: 'Swing Gate Operators',
|
||||
slide: 'Slide Gate Operators',
|
||||
barrier: 'Barrier Gate Operators',
|
||||
};
|
||||
|
||||
const categoryIcons = {
|
||||
swing: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<rect x="36" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<line x1="8" y1="20" x2="40" y2="20" stroke="currentColor" strokeWidth="3" className="stroke-blue-600" />
|
||||
<line x1="8" y1="28" x2="36" y2="28" stroke="currentColor" strokeWidth="2" className="stroke-blue-400" strokeDasharray="2 2" />
|
||||
<circle cx="32" cy="28" r="3" className="fill-yellow-400" />
|
||||
</svg>
|
||||
),
|
||||
slide: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="8" height="32" rx="2" className="fill-blue-200" />
|
||||
<rect x="36" y="16" width="8" height="24" rx="1" className="fill-blue-200" />
|
||||
<rect x="12" y="24" width="24" height="6" rx="1" className="fill-blue-600" />
|
||||
<circle cx="14" cy="16" r="2" className="fill-gray-400" />
|
||||
<circle cx="14" cy="36" r="2" className="fill-gray-400" />
|
||||
</svg>
|
||||
),
|
||||
barrier: (
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="8" width="6" height="34" rx="2" className="fill-blue-200" />
|
||||
<rect x="22" y="8" width="6" height="34" rx="2" className="fill-blue-200" />
|
||||
<rect x="2" y="12" width="28" height="6" rx="1" className="fill-blue-600" />
|
||||
<rect x="26" y="14" width="34" height="4" rx="1" className="fill-yellow-400" transform="rotate(30 26 14)" />
|
||||
</svg>
|
||||
),
|
||||
tilt: 'Tilt Gate Operators',
|
||||
};
|
||||
|
||||
export default function StepOperator({ operators, selected, onSelect }) {
|
||||
@ -52,53 +26,186 @@ export default function StepOperator({ operators, selected, onSelect }) {
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{operators
|
||||
.filter((o) => o.category === cat)
|
||||
.map((op) => (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => onSelect(op.id)}
|
||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selected === op.id
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`shrink-0 p-2 rounded-lg ${
|
||||
selected === op.id ? 'bg-blue-100' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{categoryIcons[cat]}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{op.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
Model: {op.model}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{op.description}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
C${op.basePrice.toLocaleString('en-CA')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">base</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
||||
{op.requiredParts.length} parts included
|
||||
.filter((o) => o.category === cat && !o.isCustom)
|
||||
.map((op) => {
|
||||
const isSelected = selected === op.id;
|
||||
return (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => onSelect(op.id)}
|
||||
className={`text-left p-0 rounded-xl border-2 transition-all overflow-hidden ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:w-44 shrink-0 bg-gray-50 flex items-center justify-center relative">
|
||||
<img
|
||||
src={'/images/operators/' + op.imageFile}
|
||||
alt={op.name}
|
||||
className="w-full h-44 sm:h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex'; }}
|
||||
/>
|
||||
<span className="flex items-center justify-center text-gray-400 text-sm p-4 text-center absolute inset-0" style={{display: 'none'}}>
|
||||
{op.model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-5">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{op.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-0.5">
|
||||
Model: {op.model}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{op.description}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
C{op.basePrice.toLocaleString('en-CA')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">base</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
||||
{op.requiredParts.length} parts included
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{cat === 'tilt' && <TiltOperatorCard selected={selected} onSelect={onSelect} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TiltOperatorCard({ selected, onSelect }) {
|
||||
const [model, setModel] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const isTiltSelected = selected && typeof selected === 'object';
|
||||
|
||||
const handleSelect = () => {
|
||||
const newErrors = {};
|
||||
if (!model.trim()) newErrors.model = 'Model is required';
|
||||
if (!description.trim()) newErrors.description = 'Description is required';
|
||||
if (!price || isNaN(Number(price)) || Number(price) <= 0) newErrors.price = 'Enter a valid price';
|
||||
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
onSelect({
|
||||
id: 'tilt',
|
||||
tiltModel: model.trim(),
|
||||
tiltDescription: description.trim(),
|
||||
tiltPrice: Number(price),
|
||||
tiltName: 'Tilt Gate Operator',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselect = () => {
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
const displayModel = isTiltSelected ? selected.tiltModel : model;
|
||||
const displayDescription = isTiltSelected ? selected.tiltDescription : description;
|
||||
const displayPrice = isTiltSelected ? selected.tiltPrice : price;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border-2 transition-all overflow-hidden ${
|
||||
isTiltSelected
|
||||
? 'border-blue-600 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="font-semibold text-gray-900">Custom Tilt Gate Operator</div>
|
||||
{isTiltSelected && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. TG-500"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.model ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.model && <p className="text-xs text-red-500 mt-1">{errors.model}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayDescription}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. Heavy-duty tilt gate operator, 24V DC"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.description ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-500 mt-1">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Price (C$)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={displayPrice}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
disabled={isTiltSelected}
|
||||
placeholder="e.g. 2995"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.price ? 'border-red-400 bg-red-50' : 'border-gray-300'
|
||||
} ${isTiltSelected ? 'bg-gray-100 text-gray-500 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
{errors.price && <p className="text-xs text-red-500 mt-1">{errors.price}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isTiltSelected ? (
|
||||
<button
|
||||
onClick={handleDeselect}
|
||||
className="w-full py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Deselect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSelect}
|
||||
className="w-full py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Select Custom Tilt Operator
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,8 +11,11 @@ const initialState = {
|
||||
needed: false,
|
||||
style: null,
|
||||
types: [],
|
||||
typeSizes: {},
|
||||
},
|
||||
accessControl: [],
|
||||
remoteButtons: 4,
|
||||
remoteQuantity: 1,
|
||||
};
|
||||
|
||||
function reducer(state, action) {
|
||||
@ -22,7 +25,7 @@ function reducer(state, action) {
|
||||
case 'SET_GROUND_LOOPS_NEEDED':
|
||||
return {
|
||||
...state,
|
||||
groundLoops: { needed: action.payload, style: null, types: [] },
|
||||
groundLoops: { needed: action.payload, style: null, types: [], typeSizes: {} },
|
||||
step: action.payload ? state.step : state.step + 1,
|
||||
};
|
||||
case 'SET_GROUND_LOOP_STYLE':
|
||||
@ -33,6 +36,12 @@ function reducer(state, action) {
|
||||
case 'TOGGLE_GROUND_LOOP_TYPE': {
|
||||
const id = action.payload;
|
||||
const exists = state.groundLoops.types.includes(id);
|
||||
const newTypeSizes = { ...state.groundLoops.typeSizes };
|
||||
if (!exists) {
|
||||
newTypeSizes[id] = '4x8';
|
||||
} else {
|
||||
delete newTypeSizes[id];
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
groundLoops: {
|
||||
@ -40,6 +49,7 @@ function reducer(state, action) {
|
||||
types: exists
|
||||
? state.groundLoops.types.filter((t) => t !== id)
|
||||
: [...state.groundLoops.types, id],
|
||||
typeSizes: newTypeSizes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -51,12 +61,28 @@ function reducer(state, action) {
|
||||
accessControl: exists
|
||||
? state.accessControl.filter((a) => a !== id)
|
||||
: [...state.accessControl, id],
|
||||
remoteButtons: id === 'remote-kit' && !exists ? 4 : state.remoteButtons,
|
||||
};
|
||||
}
|
||||
case 'SET_REMOTE_BUTTONS':
|
||||
return { ...state, remoteButtons: action.payload };
|
||||
case 'SET_REMOTE_QUANTITY':
|
||||
return { ...state, remoteQuantity: Math.max(1, action.payload) };
|
||||
case 'NEXT_STEP':
|
||||
return { ...state, step: Math.min(state.step + 1, 4) };
|
||||
case 'PREV_STEP':
|
||||
return { ...state, step: Math.max(state.step - 1, 1) };
|
||||
case 'SET_GROUND_LOOP_SIZE':
|
||||
return {
|
||||
...state,
|
||||
groundLoops: {
|
||||
...state.groundLoops,
|
||||
typeSizes: {
|
||||
...state.groundLoops.typeSizes,
|
||||
[action.payload.typeId]: action.payload.sizeId,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'GO_TO_STEP':
|
||||
return { ...state, step: Math.min(action.payload, 4) };
|
||||
case 'RESET':
|
||||
@ -66,9 +92,9 @@ function reducer(state, action) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Wizard({ pricing }) {
|
||||
export default function Wizard({ pricing, onLogout }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { step, operator, groundLoops, accessControl } = state;
|
||||
const { step, operator, groundLoops, accessControl, remoteButtons, remoteQuantity } = state;
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: 'Operator' },
|
||||
@ -79,7 +105,17 @@ export default function Wizard({ pricing }) {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-center mb-8 relative">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="absolute right-0 top-0 text-xs text-gray-400 hover:text-gray-600 transition-colors flex items-center gap-1"
|
||||
title="Logout"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Gate Operator Quotation System
|
||||
</h1>
|
||||
@ -141,12 +177,15 @@ export default function Wizard({ pricing }) {
|
||||
|
||||
{step === 2 && (
|
||||
<StepGroundLoops
|
||||
detectors={pricing.loopDetectors}
|
||||
styles={pricing.groundLoopStyles}
|
||||
sizes={pricing.groundLoopSizes}
|
||||
types={pricing.groundLoopTypes}
|
||||
value={groundLoops}
|
||||
onSetNeeded={(needed) => dispatch({ type: 'SET_GROUND_LOOPS_NEEDED', payload: needed })}
|
||||
onSetStyle={(id) => dispatch({ type: 'SET_GROUND_LOOP_STYLE', payload: id })}
|
||||
onToggleType={(id) => dispatch({ type: 'TOGGLE_GROUND_LOOP_TYPE', payload: id })}
|
||||
onSetSize={(typeId, sizeId) => dispatch({ type: 'SET_GROUND_LOOP_SIZE', payload: { typeId, sizeId } })}
|
||||
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
/>
|
||||
@ -155,8 +194,13 @@ export default function Wizard({ pricing }) {
|
||||
{step === 3 && (
|
||||
<StepAccessControl
|
||||
accessControl={pricing.accessControl}
|
||||
remoteButtonOptions={pricing.remoteButtonOptions}
|
||||
selected={accessControl}
|
||||
remoteButtons={remoteButtons}
|
||||
remoteQuantity={remoteQuantity}
|
||||
onToggle={(id) => dispatch({ type: 'TOGGLE_ACCESS_CONTROL', payload: id })}
|
||||
onSetRemoteButtons={(count) => dispatch({ type: 'SET_REMOTE_BUTTONS', payload: count })}
|
||||
onSetRemoteQuantity={(qty) => dispatch({ type: 'SET_REMOTE_QUANTITY', payload: qty })}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
onNext={() => dispatch({ type: 'NEXT_STEP' })}
|
||||
/>
|
||||
@ -165,7 +209,7 @@ export default function Wizard({ pricing }) {
|
||||
{step === 4 && (
|
||||
<QuoteSummary
|
||||
pricing={pricing}
|
||||
selections={{ operator, groundLoops, accessControl }}
|
||||
selections={{ operator, groundLoops, accessControl, remoteButtons, remoteQuantity }}
|
||||
onBack={() => dispatch({ type: 'PREV_STEP' })}
|
||||
onNew={() => dispatch({ type: 'RESET' })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user