Files
thrilltrack-explorer/src-old/components/admin/editors/CoasterStatsEditor.tsx

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}