mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 08:31:13 -05:00
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import { Plus, Trash2 } 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 { Textarea } from "@/components/ui/textarea";
|
||
import { Card } from "@/components/ui/card";
|
||
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
||
import { toast } from "sonner";
|
||
import {
|
||
convertValueToMetric,
|
||
convertValueFromMetric,
|
||
detectUnitType,
|
||
getMetricUnit,
|
||
getDisplayUnit
|
||
} from "@/lib/units";
|
||
import { validateMetricUnit } from "@/lib/unitValidation";
|
||
import { getErrorMessage } from "@/lib/errorHandler";
|
||
|
||
interface CoasterStat {
|
||
stat_name: string;
|
||
stat_value: number;
|
||
unit?: string;
|
||
category?: string;
|
||
description?: string;
|
||
display_order: number;
|
||
}
|
||
|
||
interface CoasterStatsEditorProps {
|
||
stats: CoasterStat[];
|
||
onChange: (stats: CoasterStat[]) => void;
|
||
categories?: string[];
|
||
}
|
||
|
||
const DEFAULT_CATEGORIES = ['Speed', 'Height', 'Length', 'Forces', 'Capacity', 'Duration', 'Other'];
|
||
const COMMON_STATS = [
|
||
{ name: 'Max Speed', unit: 'km/h', category: 'Speed' },
|
||
{ name: 'Max Height', unit: 'm', category: 'Height' },
|
||
{ name: 'Drop Height', unit: 'm', category: 'Height' },
|
||
{ name: 'Track Length', unit: 'm', category: 'Length' },
|
||
{ name: 'Max G-Force', unit: 'G', category: 'Forces' },
|
||
{ name: 'Max Negative G-Force', unit: 'G', category: 'Forces' },
|
||
{ name: 'Ride Duration', unit: 'seconds', category: 'Duration' },
|
||
{ name: 'Inversions', unit: 'count', category: 'Other' },
|
||
];
|
||
|
||
export function CoasterStatsEditor({
|
||
stats,
|
||
onChange,
|
||
categories = DEFAULT_CATEGORIES
|
||
}: CoasterStatsEditorProps) {
|
||
const { preferences } = useUnitPreferences();
|
||
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
|
||
|
||
const addStat = () => {
|
||
onChange([
|
||
...stats,
|
||
{
|
||
stat_name: '',
|
||
stat_value: 0,
|
||
unit: '',
|
||
category: categories[0],
|
||
description: '',
|
||
display_order: stats.length
|
||
}
|
||
]);
|
||
};
|
||
|
||
const addCommonStat = (commonStat: typeof COMMON_STATS[0]) => {
|
||
onChange([
|
||
...stats,
|
||
{
|
||
stat_name: commonStat.name,
|
||
stat_value: 0,
|
||
unit: commonStat.unit,
|
||
category: commonStat.category,
|
||
description: '',
|
||
display_order: stats.length
|
||
}
|
||
]);
|
||
};
|
||
|
||
const removeStat = (index: number) => {
|
||
const newStats = stats.filter((_, i) => i !== index);
|
||
onChange(newStats.map((stat, i) => ({ ...stat, display_order: i })));
|
||
};
|
||
|
||
const updateStat = (index: number, field: keyof CoasterStat, value: string | number | boolean | null | undefined) => {
|
||
const newStats = [...stats];
|
||
|
||
// Ensure unit is metric when updating unit field
|
||
if (field === 'unit' && value && typeof value === 'string') {
|
||
try {
|
||
validateMetricUnit(value, 'Unit');
|
||
newStats[index] = { ...newStats[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 {
|
||
newStats[index] = { ...newStats[index], [field]: value };
|
||
}
|
||
|
||
onChange(newStats);
|
||
};
|
||
|
||
// Get display value (convert from metric to user's preferred units)
|
||
const getDisplayValue = (stat: CoasterStat): string => {
|
||
if (!stat.stat_value || !stat.unit) return String(stat.stat_value || '');
|
||
|
||
const numValue = Number(stat.stat_value);
|
||
if (isNaN(numValue)) return String(stat.stat_value);
|
||
|
||
const unitType = detectUnitType(stat.unit);
|
||
if (unitType === 'unknown') return String(stat.stat_value);
|
||
|
||
// stat.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(stat.unit, preferences.measurement_system);
|
||
|
||
// Convert from metric to display unit
|
||
const displayValue = convertValueFromMetric(numValue, displayUnit, stat.unit);
|
||
|
||
return String(displayValue);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<Label>Coaster Statistics</Label>
|
||
<div className="flex gap-2">
|
||
<Select onValueChange={(value) => {
|
||
const commonStat = COMMON_STATS.find(s => s.name === value);
|
||
if (commonStat) addCommonStat(commonStat);
|
||
}}>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="Add common stat..." />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{COMMON_STATS.map(stat => (
|
||
<SelectItem key={stat.name} value={stat.name}>
|
||
{stat.name} ({stat.unit})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" variant="outline" size="sm" onClick={addStat}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Add Custom
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{stats.length === 0 ? (
|
||
<Card className="p-6 text-center text-muted-foreground">
|
||
No statistics added yet. Add a common stat or create a custom one.
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{stats.map((stat, index) => (
|
||
<Card key={index} className="p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<Label className="text-xs">Statistic Name</Label>
|
||
<Input
|
||
value={stat.stat_name}
|
||
onChange={(e) => updateStat(index, 'stat_name', e.target.value)}
|
||
placeholder="e.g., Max Speed"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">Value</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={getDisplayValue(stat)}
|
||
onChange={(e) => {
|
||
const inputValue = e.target.value;
|
||
const numValue = parseFloat(inputValue);
|
||
|
||
if (!isNaN(numValue) && stat.unit) {
|
||
// Determine what unit the user is entering (based on their preference)
|
||
const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system);
|
||
// Convert from user's input unit to metric for storage
|
||
const metricValue = convertValueToMetric(numValue, displayUnit);
|
||
updateStat(index, 'stat_value', metricValue);
|
||
} else {
|
||
updateStat(index, 'stat_value', numValue || 0);
|
||
}
|
||
}}
|
||
placeholder="0"
|
||
/>
|
||
{stat.unit && detectUnitType(stat.unit) !== 'unknown' && (
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Enter in {getDisplayUnit(stat.unit, preferences.measurement_system)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Unit</Label>
|
||
<Input
|
||
value={stat.unit || ''}
|
||
onChange={(e) => updateStat(index, 'unit', e.target.value)}
|
||
placeholder="km/h, m, G..."
|
||
className={unitErrors[index] ? 'border-destructive' : ''}
|
||
/>
|
||
{unitErrors[index] && (
|
||
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
⚠️ Use metric units only: km/h, m, cm, kg, G, celsius
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">Category</Label>
|
||
<Select
|
||
value={stat.category || ''}
|
||
onValueChange={(value) => updateStat(index, 'category', value)}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select category" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map(cat => (
|
||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-end">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeStat(index)}
|
||
className="ml-auto"
|
||
>
|
||
<Trash2 className="h-4 w-4 text-destructive mr-2" />
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<Label className="text-xs">Description (optional)</Label>
|
||
<Textarea
|
||
value={stat.description || ''}
|
||
onChange={(e) => updateStat(index, 'description', e.target.value)}
|
||
placeholder="Additional context about this statistic..."
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Validates coaster stats before submission
|
||
*/
|
||
export function validateCoasterStats(stats: CoasterStat[]): { valid: boolean; errors: string[] } {
|
||
const errors: string[] = [];
|
||
|
||
stats.forEach((stat, index) => {
|
||
if (!stat.stat_name?.trim()) {
|
||
errors.push(`Stat ${index + 1}: Name is required`);
|
||
}
|
||
if (stat.stat_value === null || stat.stat_value === undefined) {
|
||
errors.push(`Stat ${index + 1} (${stat.stat_name}): Value is required`);
|
||
}
|
||
if (stat.unit) {
|
||
try {
|
||
validateMetricUnit(stat.unit, `Stat ${index + 1} (${stat.stat_name})`);
|
||
} catch (error: unknown) {
|
||
errors.push(getErrorMessage(error));
|
||
}
|
||
}
|
||
});
|
||
|
||
return { valid: errors.length === 0, errors };
|
||
}
|