mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 04:31:13 -05:00
Implements enhanced inline validation with contextual messages and examples, adds a comprehensive theme park terminology tool (tooltip and glossary), and integrates these features into ParkForm and RideForm (including header actions and descriptive hints). Also introduces new helper modules and components to support validated inputs and glossary tooltips.
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import { Plus, Trash2, HelpCircle } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Card } from "@/components/ui/card";
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
||
import { toast } from "sonner";
|
||
import { fieldHints } from "@/lib/enhancedValidation";
|
||
import {
|
||
convertValueToMetric,
|
||
convertValueFromMetric,
|
||
detectUnitType,
|
||
getMetricUnit,
|
||
getDisplayUnit
|
||
} from "@/lib/units";
|
||
import { validateMetricUnit, METRIC_UNITS } from "@/lib/unitValidation";
|
||
import { getErrorMessage } from "@/lib/errorHandler";
|
||
|
||
interface TechnicalSpec {
|
||
spec_name: string;
|
||
spec_value: string;
|
||
spec_type: 'string' | 'number' | 'boolean' | 'date';
|
||
category?: string;
|
||
unit?: string;
|
||
display_order: number;
|
||
}
|
||
|
||
interface TechnicalSpecsEditorProps {
|
||
specs: TechnicalSpec[];
|
||
onChange: (specs: TechnicalSpec[]) => void;
|
||
categories?: string[];
|
||
commonSpecs?: string[];
|
||
}
|
||
|
||
const DEFAULT_CATEGORIES = ['Performance', 'Safety', 'Design', 'Capacity', 'Technical', 'Other'];
|
||
|
||
export function TechnicalSpecsEditor({
|
||
specs,
|
||
onChange,
|
||
categories = DEFAULT_CATEGORIES,
|
||
commonSpecs = []
|
||
}: TechnicalSpecsEditorProps) {
|
||
const { preferences } = useUnitPreferences();
|
||
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
|
||
|
||
const addSpec = () => {
|
||
onChange([
|
||
...specs,
|
||
{
|
||
spec_name: '',
|
||
spec_value: '',
|
||
spec_type: 'string',
|
||
category: categories[0],
|
||
unit: '',
|
||
display_order: specs.length
|
||
}
|
||
]);
|
||
};
|
||
|
||
const removeSpec = (index: number) => {
|
||
const newSpecs = specs.filter((_, i) => i !== index);
|
||
// Reorder display_order
|
||
onChange(newSpecs.map((spec, i) => ({ ...spec, display_order: i })));
|
||
};
|
||
|
||
const updateSpec = (index: number, field: keyof TechnicalSpec, value: string | number | boolean | null | undefined) => {
|
||
const newSpecs = [...specs];
|
||
|
||
// Ensure unit is metric when updating unit field
|
||
if (field === 'unit' && value && typeof value === 'string') {
|
||
try {
|
||
validateMetricUnit(value, 'Unit');
|
||
newSpecs[index] = { ...newSpecs[index], unit: value };
|
||
// Clear error for this index
|
||
setUnitErrors(prev => {
|
||
const updated = { ...prev };
|
||
delete updated[index];
|
||
return updated;
|
||
});
|
||
} catch (error: unknown) {
|
||
const message = getErrorMessage(error);
|
||
toast.error(message);
|
||
// Store error for visual feedback
|
||
setUnitErrors(prev => ({ ...prev, [index]: message }));
|
||
return;
|
||
}
|
||
} else {
|
||
newSpecs[index] = { ...newSpecs[index], [field]: value };
|
||
}
|
||
|
||
onChange(newSpecs);
|
||
};
|
||
|
||
// Get display value (convert from metric to user's preferred units)
|
||
const getDisplayValue = (spec: TechnicalSpec): string => {
|
||
if (!spec.spec_value || !spec.unit || spec.spec_type !== 'number') return spec.spec_value;
|
||
|
||
const numValue = parseFloat(spec.spec_value);
|
||
if (isNaN(numValue)) return spec.spec_value;
|
||
|
||
const unitType = detectUnitType(spec.unit);
|
||
if (unitType === 'unknown') return spec.spec_value;
|
||
|
||
// spec.unit is the metric unit (e.g., "km/h")
|
||
// Get the display unit based on user preference (e.g., "mph" for imperial)
|
||
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
|
||
|
||
// Convert from metric to display unit
|
||
const displayValue = convertValueFromMetric(numValue, displayUnit, spec.unit);
|
||
|
||
return String(displayValue);
|
||
};
|
||
|
||
const moveSpec = (index: number, direction: 'up' | 'down') => {
|
||
if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) {
|
||
return;
|
||
}
|
||
const newSpecs = [...specs];
|
||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||
[newSpecs[index], newSpecs[swapIndex]] = [newSpecs[swapIndex], newSpecs[index]];
|
||
// Update display_order
|
||
newSpecs[index].display_order = index;
|
||
newSpecs[swapIndex].display_order = swapIndex;
|
||
onChange(newSpecs);
|
||
};
|
||
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Label>Technical Specifications</Label>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<p>Add custom specifications like track material (Steel, Wood), propulsion method (LSM Launch, Chain Lift), train type, etc. Use metric units only.</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<Button type="button" variant="outline" size="sm" onClick={addSpec}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Add Specification
|
||
</Button>
|
||
</div>
|
||
|
||
{specs.length === 0 ? (
|
||
<Card className="p-6 text-center text-muted-foreground">
|
||
No specifications added yet. Click "Add Specification" to get started.
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{specs.map((spec, index) => (
|
||
<Card key={index} className="p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
|
||
<div className="lg:col-span-2">
|
||
<div className="flex items-center gap-1">
|
||
<Label className="text-xs">Specification Name</Label>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<p className="font-semibold mb-1">Examples:</p>
|
||
<ul className="text-xs space-y-1">
|
||
<li>• Track Material (Steel/Wood)</li>
|
||
<li>• Propulsion Method (LSM Launch, Chain Lift)</li>
|
||
<li>• Train Type (Sit-down, Inverted)</li>
|
||
<li>• Restraint System (Lap bar, OTSR)</li>
|
||
<li>• Launch Speed (km/h)</li>
|
||
</ul>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<Input
|
||
value={spec.spec_name}
|
||
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
|
||
placeholder="e.g., Track Material"
|
||
list={`common-specs-${index}`}
|
||
/>
|
||
{commonSpecs.length > 0 && (
|
||
<datalist id={`common-specs-${index}`}>
|
||
{commonSpecs.map(s => <option key={s} value={s} />)}
|
||
</datalist>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">Value</Label>
|
||
<Input
|
||
value={getDisplayValue(spec)}
|
||
onChange={(e) => {
|
||
const inputValue = e.target.value;
|
||
const numValue = parseFloat(inputValue);
|
||
|
||
// If type is number and unit is recognized, convert to metric for storage
|
||
if (spec.spec_type === 'number' && spec.unit && !isNaN(numValue)) {
|
||
// Determine what unit the user is entering (based on their preference)
|
||
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
|
||
// Convert from user's input unit to metric for storage
|
||
const metricValue = convertValueToMetric(numValue, displayUnit);
|
||
updateSpec(index, 'spec_value', String(metricValue));
|
||
} else {
|
||
updateSpec(index, 'spec_value', inputValue);
|
||
}
|
||
}}
|
||
placeholder="Value"
|
||
type={spec.spec_type === 'number' ? 'number' : 'text'}
|
||
/>
|
||
{spec.spec_type === 'number' && spec.unit && detectUnitType(spec.unit) !== 'unknown' && (
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Enter in {getDisplayUnit(spec.unit, preferences.measurement_system)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center gap-1">
|
||
<Label className="text-xs">Type</Label>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<ul className="text-xs space-y-1">
|
||
<li>• <strong>Text:</strong> Material names, methods (e.g., "Steel", "LSM Launch")</li>
|
||
<li>• <strong>Number:</strong> Measurements with units (e.g., speed, length)</li>
|
||
<li>• <strong>Yes/No:</strong> Features (e.g., "Has VR")</li>
|
||
<li>• <strong>Date:</strong> Installation dates</li>
|
||
</ul>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<Select
|
||
value={spec.spec_type}
|
||
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="string">Text</SelectItem>
|
||
<SelectItem value="number">Number</SelectItem>
|
||
<SelectItem value="boolean">Yes/No</SelectItem>
|
||
<SelectItem value="date">Date</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">Category</Label>
|
||
<Select
|
||
value={spec.category || ''}
|
||
onValueChange={(value) => updateSpec(index, 'category', value)}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map(cat => (
|
||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-end gap-2">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-1">
|
||
<Label className="text-xs">Unit</Label>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<p className="font-semibold mb-1">Metric units only:</p>
|
||
<ul className="text-xs space-y-1">
|
||
<li>• Speed: km/h (not mph)</li>
|
||
<li>• Distance: m, km, cm (not ft, mi, in)</li>
|
||
<li>• Weight: kg, g (not lb, oz)</li>
|
||
<li>• Leave empty for text values</li>
|
||
</ul>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<Input
|
||
value={spec.unit || ''}
|
||
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
|
||
placeholder="Unit"
|
||
list={`units-${index}`}
|
||
className={unitErrors[index] ? 'border-destructive' : ''}
|
||
/>
|
||
<datalist id={`units-${index}`}>
|
||
{METRIC_UNITS.map(u => <option key={u} value={u} />)}
|
||
</datalist>
|
||
{unitErrors[index] && (
|
||
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
⚠️ Metric units only
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeSpec(index)}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Validates technical specs before submission
|
||
*/
|
||
export function validateTechnicalSpecs(specs: TechnicalSpec[]): { valid: boolean; errors: string[] } {
|
||
const errors: string[] = [];
|
||
|
||
specs.forEach((spec, index) => {
|
||
if (!spec.spec_name?.trim()) {
|
||
errors.push(`Spec ${index + 1}: Name is required`);
|
||
}
|
||
if (!spec.spec_value?.trim()) {
|
||
errors.push(`Spec ${index + 1} (${spec.spec_name}): Value is required`);
|
||
}
|
||
if (spec.unit) {
|
||
try {
|
||
validateMetricUnit(spec.unit, `Spec ${index + 1} (${spec.spec_name})`);
|
||
} catch (error: unknown) {
|
||
errors.push(getErrorMessage(error));
|
||
}
|
||
}
|
||
});
|
||
|
||
return { valid: errors.length === 0, errors };
|
||
}
|