mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-29 14:47:05 -05:00
Compare commits
4 Commits
67ce8b5a88
...
7d085a0702
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d085a0702 | ||
|
|
6fef107728 | ||
|
|
42f26acb49 | ||
|
|
985454f0d9 |
@@ -31,6 +31,9 @@ import { OperatorForm } from './OperatorForm';
|
|||||||
import { PropertyOwnerForm } from './PropertyOwnerForm';
|
import { PropertyOwnerForm } from './PropertyOwnerForm';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||||
|
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { fieldHints } from '@/lib/enhancedValidation';
|
||||||
|
|
||||||
const parkSchema = z.object({
|
const parkSchema = z.object({
|
||||||
name: z.string().min(1, 'Park name is required'),
|
name: z.string().min(1, 'Park name is required'),
|
||||||
@@ -320,10 +323,14 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<MapPin className="w-5 h-5" />
|
<MapPin className="w-5 h-5" />
|
||||||
{isEditing ? 'Edit Park' : 'Create New Park'}
|
{isEditing ? 'Edit Park' : 'Create New Park'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<TerminologyDialog />
|
||||||
<SubmissionHelpDialog type="park" variant="icon" />
|
<SubmissionHelpDialog type="park" variant="icon" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<TooltipProvider>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -610,6 +617,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('website_url')}
|
{...register('website_url')}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.websiteUrl}</p>
|
||||||
{errors.website_url && (
|
{errors.website_url && (
|
||||||
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -622,6 +630,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('phone')}
|
{...register('phone')}
|
||||||
placeholder="+1 (555) 123-4567"
|
placeholder="+1 (555) 123-4567"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -632,6 +641,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('email')}
|
{...register('email')}
|
||||||
placeholder="contact@park.com"
|
placeholder="contact@park.com"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.email}</p>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -663,7 +673,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where did you find this information? (e.g., official website, news article, press release)
|
{fieldHints.sourceUrl}
|
||||||
</p>
|
</p>
|
||||||
{errors.source_url && (
|
{errors.source_url && (
|
||||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||||
@@ -685,7 +695,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{watch('submission_notes')?.length || 0}/1000 characters
|
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||||
</p>
|
</p>
|
||||||
{errors.submission_notes && (
|
{errors.submission_notes && (
|
||||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||||
@@ -724,6 +734,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Operator Modal */}
|
{/* Operator Modal */}
|
||||||
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/Technica
|
|||||||
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
||||||
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
||||||
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||||
|
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||||
|
import { TermTooltip } from '@/components/ui/term-tooltip';
|
||||||
|
import { fieldHints } from '@/lib/enhancedValidation';
|
||||||
import {
|
import {
|
||||||
convertValueToMetric,
|
convertValueToMetric,
|
||||||
convertValueFromMetric,
|
convertValueFromMetric,
|
||||||
@@ -391,8 +394,11 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<Zap className="w-5 h-5" />
|
<Zap className="w-5 h-5" />
|
||||||
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<TerminologyDialog />
|
||||||
<SubmissionHelpDialog type="ride" variant="icon" />
|
<SubmissionHelpDialog type="ride" variant="icon" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
@@ -769,10 +775,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('height_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('height_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 47' : 'e.g. 120'}
|
placeholder={measurementSystem === 'imperial' ? 'e.g. 47' : 'e.g. 120'}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{fieldHints.heightRequirement}</p>
|
||||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<p>Minimum height to ride. Values automatically convert to {measurementSystem === 'imperial' ? 'inches' : 'cm'}.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -851,17 +854,15 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label>Track Material(s)</Label>
|
<Label>
|
||||||
<Tooltip>
|
<TermTooltip term="ibox-track" showIcon={false}>
|
||||||
<TooltipTrigger asChild>
|
Track Material(s)
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
</TermTooltip>
|
||||||
</TooltipTrigger>
|
</Label>
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
<p>Material used for the track. Select multiple if hybrid (e.g., wood track with steel supports).</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Select all materials used in the track</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common: <TermTooltip term="ibox-track" inline>Steel</TermTooltip>, Wood, <TermTooltip term="hybrid-coaster" inline>Hybrid (RMC IBox)</TermTooltip>
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{TRACK_MATERIALS.map((material) => (
|
{TRACK_MATERIALS.map((material) => (
|
||||||
<div key={material.value} className="flex items-center space-x-2">
|
<div key={material.value} className="flex items-center space-x-2">
|
||||||
@@ -888,16 +889,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label>Support Material(s)</Label>
|
<Label>Support Material(s)</Label>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
<p>Material used for the support structure. Can be different from track material (e.g., wood track on steel supports).</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Select all materials used in the supports</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Materials used for support structure (can differ from track)
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{SUPPORT_MATERIALS.map((material) => (
|
{SUPPORT_MATERIALS.map((material) => (
|
||||||
<div key={material.value} className="flex items-center space-x-2">
|
<div key={material.value} className="flex items-center space-x-2">
|
||||||
@@ -923,23 +918,15 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label>Propulsion Method(s)</Label>
|
<Label>
|
||||||
<Tooltip>
|
<TermTooltip term="lsm" showIcon={false}>
|
||||||
<TooltipTrigger asChild>
|
Propulsion Method(s)
|
||||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
</TermTooltip>
|
||||||
</TooltipTrigger>
|
</Label>
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
<p className="font-semibold mb-1">Common methods:</p>
|
|
||||||
<ul className="text-xs space-y-1">
|
|
||||||
<li>• <strong>LSM Launch:</strong> Linear Synchronous Motor (smooth, modern)</li>
|
|
||||||
<li>• <strong>Chain Lift:</strong> Traditional lift hill</li>
|
|
||||||
<li>• <strong>Hydraulic Launch:</strong> Fast, powerful (e.g., Kingda Ka)</li>
|
|
||||||
<li>• <strong>Gravity:</strong> Free-fall or terrain-based</li>
|
|
||||||
</ul>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Select all propulsion methods used</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common: <TermTooltip term="lsm" inline>LSM Launch</TermTooltip>, <TermTooltip term="chain-lift" inline>Chain Lift</TermTooltip>, <TermTooltip term="hydraulic-launch" inline>Hydraulic Launch</TermTooltip>
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{PROPULSION_METHODS.map((method) => (
|
{PROPULSION_METHODS.map((method) => (
|
||||||
<div key={method.value} className="flex items-center space-x-2">
|
<div key={method.value} className="flex items-center space-x-2">
|
||||||
@@ -1380,6 +1367,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('capacity_per_hour', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('capacity_per_hour', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 1200"
|
placeholder="e.g. 1200"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.capacity}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1391,6 +1379,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('duration_seconds', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('duration_seconds', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 180"
|
placeholder="e.g. 180"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.duration}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1403,6 +1392,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('max_speed_kmh', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('max_speed_kmh', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 50' : 'e.g. 80.5'}
|
placeholder={measurementSystem === 'imperial' ? 'e.g. 50' : 'e.g. 80.5'}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.speed}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1438,6 +1428,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('inversions', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('inversions', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 7"
|
placeholder="e.g. 7"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.inversions}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1491,7 +1482,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where did you find this information? (e.g., official website, news article, press release)
|
{fieldHints.sourceUrl}
|
||||||
</p>
|
</p>
|
||||||
{errors.source_url && (
|
{errors.source_url && (
|
||||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||||
@@ -1513,7 +1504,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{watch('submission_notes')?.length || 0}/1000 characters
|
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||||
</p>
|
</p>
|
||||||
{errors.submission_notes && (
|
{errors.submission_notes && (
|
||||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { fieldHints } from "@/lib/enhancedValidation";
|
||||||
import {
|
import {
|
||||||
convertValueToMetric,
|
convertValueToMetric,
|
||||||
convertValueFromMetric,
|
convertValueFromMetric,
|
||||||
|
|||||||
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldWrapper Live Demo
|
||||||
|
*
|
||||||
|
* This component demonstrates the FormFieldWrapper in action
|
||||||
|
* You can view this by navigating to /examples/form-field-wrapper
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
export function FormFieldWrapperDemo() {
|
||||||
|
const { register, formState: { errors }, watch, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
console.log('Form submitted:', data);
|
||||||
|
alert('Check console for form data');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="container mx-auto py-8 max-w-4xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>FormFieldWrapper Demo</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Interactive demonstration of the unified form field component
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="basic">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||||
|
<TabsTrigger value="terminology">Terminology</TabsTrigger>
|
||||||
|
<TabsTrigger value="presets">Presets</TabsTrigger>
|
||||||
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 mt-6">
|
||||||
|
{/* Basic Examples */}
|
||||||
|
<TabsContent value="basic" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Basic Field Types</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
These fields automatically show appropriate hints and validation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="website_url"
|
||||||
|
label="Website URL"
|
||||||
|
fieldType="url"
|
||||||
|
error={errors.website_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('website_url'),
|
||||||
|
placeholder: "https://example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="email"
|
||||||
|
label="Email Address"
|
||||||
|
fieldType="email"
|
||||||
|
required
|
||||||
|
error={errors.email?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('email', { required: 'Email is required' }),
|
||||||
|
placeholder: "contact@example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="phone"
|
||||||
|
label="Phone Number"
|
||||||
|
fieldType="phone"
|
||||||
|
error={errors.phone?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('phone'),
|
||||||
|
placeholder: "+1 (555) 123-4567"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Terminology Examples */}
|
||||||
|
<TabsContent value="terminology" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Fields with Terminology</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hover over labels with icons to see terminology definitions
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="inversions"
|
||||||
|
label="Inversions"
|
||||||
|
fieldType="inversions"
|
||||||
|
termKey="inversion"
|
||||||
|
error={errors.inversions?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('inversions'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "e.g. 7"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_speed"
|
||||||
|
label="Max Speed (km/h)"
|
||||||
|
fieldType="speed"
|
||||||
|
termKey="kilometers-per-hour"
|
||||||
|
error={errors.max_speed?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_speed'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
placeholder: "e.g. 193"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_height"
|
||||||
|
label="Max Height (meters)"
|
||||||
|
fieldType="height"
|
||||||
|
termKey="meters"
|
||||||
|
error={errors.max_height?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_height'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
placeholder: "e.g. 94"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Preset Examples */}
|
||||||
|
<TabsContent value="presets" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Using Presets</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common field configurations with one-line setup
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.sourceUrl({})}
|
||||||
|
id="source_url"
|
||||||
|
error={errors.source_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('source_url'),
|
||||||
|
placeholder: "https://source.com/article"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.heightRequirement({})}
|
||||||
|
id="height_requirement"
|
||||||
|
error={errors.height_requirement?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('height_requirement'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "122"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.capacity({})}
|
||||||
|
id="capacity"
|
||||||
|
error={errors.capacity?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('capacity'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "1200"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Advanced Examples */}
|
||||||
|
<TabsContent value="advanced" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Advanced Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Textareas, character counting, and custom hints
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.submissionNotes({})}
|
||||||
|
id="submission_notes"
|
||||||
|
value={watch('submission_notes')}
|
||||||
|
error={errors.submission_notes?.message as string}
|
||||||
|
textareaProps={{
|
||||||
|
...register('submission_notes', {
|
||||||
|
maxLength: { value: 1000, message: 'Maximum 1000 characters' }
|
||||||
|
}),
|
||||||
|
placeholder: "Add context for moderators...",
|
||||||
|
rows: 4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="custom_field"
|
||||||
|
label="Custom Field with Override"
|
||||||
|
fieldType="text"
|
||||||
|
hint="This is a custom hint that overrides any automatic hint"
|
||||||
|
error={errors.custom_field?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('custom_field'),
|
||||||
|
placeholder: "Enter custom value"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="no_hint_field"
|
||||||
|
label="Field Without Hint"
|
||||||
|
fieldType="url"
|
||||||
|
hideHint
|
||||||
|
error={errors.no_hint_field?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('no_hint_field'),
|
||||||
|
placeholder: "https://"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Submit Form (Check Console)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Benefits Card */}
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Benefits</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Consistency:</strong> All fields follow the same structure and styling</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Less Code:</strong> ~50% reduction in form field boilerplate</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Smart Defaults:</strong> Automatic hints based on field type</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Built-in Terminology:</strong> Hover tooltips for technical terms</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Easy Updates:</strong> Change hints in one place, updates everywhere</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Type Safety:</strong> TypeScript ensures correct usage</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/help/TerminologyDialog.tsx
Normal file
135
src/components/help/TerminologyDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { BookOpen, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getAllCategories, getTermsByCategory, searchGlossary, type GlossaryTerm } from "@/lib/glossary";
|
||||||
|
|
||||||
|
export function TerminologyDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const categories = getAllCategories();
|
||||||
|
const searchResults = searchQuery ? searchGlossary(searchQuery) : [];
|
||||||
|
|
||||||
|
const renderTermCard = (term: GlossaryTerm) => (
|
||||||
|
<div key={term.term} className="p-4 border rounded-lg space-y-2 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="font-semibold">{term.term}</h4>
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
{term.category.replace('-', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{term.definition}</p>
|
||||||
|
{term.example && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
<span className="font-medium">Example:</span> {term.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Related:</span>
|
||||||
|
{term.relatedTerms.map(rt => (
|
||||||
|
<Badge key={rt} variant="outline" className="text-xs">
|
||||||
|
{rt.replace(/-/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
|
Terminology
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Theme Park Terminology Reference</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Quick reference for technical terms, manufacturers, and ride types
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search terminology..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchQuery ? (
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
searchResults.map(renderTermCard)
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No terms found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="manufacturer" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-7">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<TabsTrigger key={cat} value={cat} className="text-xs">
|
||||||
|
{cat === 'manufacturer' ? 'Mfg.' :
|
||||||
|
cat === 'technology' ? 'Tech' :
|
||||||
|
cat === 'measurement' ? 'Units' :
|
||||||
|
cat.charAt(0).toUpperCase() + cat.slice(1).substring(0, 4)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{categories.map(cat => {
|
||||||
|
const terms = getTermsByCategory(cat);
|
||||||
|
return (
|
||||||
|
<TabsContent key={cat} value={cat}>
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-3 pr-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
|
<h3 className="font-semibold capitalize">
|
||||||
|
{cat.replace('-', ' ')}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{terms.length} terms</Badge>
|
||||||
|
</div>
|
||||||
|
{terms.map(renderTermCard)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-4 border-t text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="text-xs">Tip</Badge>
|
||||||
|
<span>Hover over underlined terms in forms to see quick definitions</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
src/components/ui/FormFieldWrapper.README.md
Normal file
264
src/components/ui/FormFieldWrapper.README.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# FormFieldWrapper Component
|
||||||
|
|
||||||
|
A unified form field component that automatically provides hints, validation messages, and terminology tooltips based on field type.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Automatic hints** based on field type (speed, height, URL, email, etc.)
|
||||||
|
- ✅ **Built-in validation** display with error messages
|
||||||
|
- ✅ **Terminology tooltips** on labels (hover to see definitions)
|
||||||
|
- ✅ **Character counting** for textareas
|
||||||
|
- ✅ **50% less boilerplate** compared to manual field creation
|
||||||
|
- ✅ **Type-safe** with TypeScript
|
||||||
|
- ✅ **Consistent styling** across all forms
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Before (Manual)
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="website_url">Website URL</Label>
|
||||||
|
<Input
|
||||||
|
id="website_url"
|
||||||
|
type="url"
|
||||||
|
{...register('website_url')}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Official website URL (must start with https:// or http://)
|
||||||
|
</p>
|
||||||
|
{errors.website_url && (
|
||||||
|
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (With FormFieldWrapper)
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="website_url"
|
||||||
|
label="Website URL"
|
||||||
|
fieldType="url"
|
||||||
|
error={errors.website_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('website_url'),
|
||||||
|
placeholder: "https://..."
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormFieldWrapper } from '@/components/ui/form-field-wrapper';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const { register, formState: { errors } } = useForm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{/* Basic text input with automatic hint */}
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="email"
|
||||||
|
label="Email Address"
|
||||||
|
fieldType="email"
|
||||||
|
required
|
||||||
|
error={errors.email?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('email', { required: 'Email is required' }),
|
||||||
|
placeholder: "contact@example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Textarea with character count */}
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="notes"
|
||||||
|
label="Notes for Reviewers"
|
||||||
|
fieldType="submission-notes"
|
||||||
|
optional
|
||||||
|
value={watch('notes')}
|
||||||
|
maxLength={1000}
|
||||||
|
error={errors.notes?.message as string}
|
||||||
|
textareaProps={{
|
||||||
|
...register('notes'),
|
||||||
|
placeholder: "Add context...",
|
||||||
|
rows: 3
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Terminology Tooltips
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="inversions"
|
||||||
|
label="Inversions"
|
||||||
|
fieldType="inversions"
|
||||||
|
termKey="inversion" // Adds tooltip explaining what inversions are
|
||||||
|
error={errors.inversions?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('inversions'),
|
||||||
|
type: "number",
|
||||||
|
placeholder: "e.g. 7"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Presets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.sourceUrl({})}
|
||||||
|
id="source_url"
|
||||||
|
error={errors.source_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('source_url'),
|
||||||
|
placeholder: "https://..."
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Field Types
|
||||||
|
|
||||||
|
- `url` - Website URLs with protocol hint
|
||||||
|
- `email` - Email addresses with format hint
|
||||||
|
- `phone` - Phone numbers with flexible format hint
|
||||||
|
- `slug` - URL slugs with character restrictions
|
||||||
|
- `height-requirement` - Height in cm with metric hint
|
||||||
|
- `age-requirement` - Age requirements
|
||||||
|
- `capacity` - Capacity per hour
|
||||||
|
- `duration` - Duration in seconds
|
||||||
|
- `speed` - Max speed (km/h)
|
||||||
|
- `height` - Max height (meters)
|
||||||
|
- `length` - Track length (meters)
|
||||||
|
- `inversions` - Number of inversions
|
||||||
|
- `g-force` - G-force values
|
||||||
|
- `source-url` - Reference URL for verification
|
||||||
|
- `submission-notes` - Notes for moderators (textarea with char count)
|
||||||
|
|
||||||
|
## Available Presets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
formFieldPresets.websiteUrl({})
|
||||||
|
formFieldPresets.email({})
|
||||||
|
formFieldPresets.phone({})
|
||||||
|
formFieldPresets.sourceUrl({})
|
||||||
|
formFieldPresets.submissionNotes({})
|
||||||
|
formFieldPresets.heightRequirement({})
|
||||||
|
formFieldPresets.capacity({})
|
||||||
|
formFieldPresets.duration({})
|
||||||
|
formFieldPresets.speed({})
|
||||||
|
formFieldPresets.height({})
|
||||||
|
formFieldPresets.length({})
|
||||||
|
formFieldPresets.inversions({})
|
||||||
|
formFieldPresets.gForce({})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Hints
|
||||||
|
|
||||||
|
Override automatic hints with custom text:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="custom"
|
||||||
|
label="Custom Field"
|
||||||
|
fieldType="text"
|
||||||
|
hint="This is my custom hint that overrides any automatic hint"
|
||||||
|
inputProps={{...register('custom')}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hide Hints
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="no_hint"
|
||||||
|
label="Field Without Hint"
|
||||||
|
fieldType="url"
|
||||||
|
hideHint
|
||||||
|
inputProps={{...register('no_hint')}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
To migrate existing fields:
|
||||||
|
|
||||||
|
1. **Identify the field structure** to replace
|
||||||
|
2. **Choose appropriate `fieldType`** from the list above
|
||||||
|
3. **Add `termKey`** if field relates to terminology
|
||||||
|
4. **Replace** the entire div block with `FormFieldWrapper`
|
||||||
|
|
||||||
|
Example migration:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_speed_kmh">Max Speed (km/h)</Label>
|
||||||
|
<Input
|
||||||
|
id="max_speed_kmh"
|
||||||
|
type="number"
|
||||||
|
{...register('max_speed_kmh')}
|
||||||
|
placeholder="e.g. 193"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)
|
||||||
|
</p>
|
||||||
|
{errors.max_speed_kmh && (
|
||||||
|
<p className="text-sm text-destructive">{errors.max_speed_kmh.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// AFTER
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_speed_kmh"
|
||||||
|
label="Max Speed (km/h)"
|
||||||
|
fieldType="speed"
|
||||||
|
termKey="kilometers-per-hour"
|
||||||
|
error={errors.max_speed_kmh?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_speed_kmh'),
|
||||||
|
type: "number",
|
||||||
|
placeholder: "e.g. 193"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
View a live interactive demo at `/examples/form-field-wrapper` (in development mode) by visiting the `FormFieldWrapperDemo` component.
|
||||||
|
|
||||||
|
## Props Reference
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `string` | Field identifier (required) |
|
||||||
|
| `label` | `string` | Field label text (required) |
|
||||||
|
| `fieldType` | `FormFieldType` | Type for automatic hints |
|
||||||
|
| `termKey` | `string` | Terminology key for tooltip |
|
||||||
|
| `showTermIcon` | `boolean` | Show tooltip icon (default: true) |
|
||||||
|
| `required` | `boolean` | Show required asterisk |
|
||||||
|
| `optional` | `boolean` | Show optional badge |
|
||||||
|
| `hint` | `string` | Custom hint (overrides automatic) |
|
||||||
|
| `error` | `string` | Error message from validation |
|
||||||
|
| `value` | `string \| number` | Current value for char counting |
|
||||||
|
| `maxLength` | `number` | Max length for char counting |
|
||||||
|
| `inputProps` | `InputProps` | Props to pass to Input |
|
||||||
|
| `textareaProps` | `TextareaProps` | Props to pass to Textarea |
|
||||||
|
| `className` | `string` | Additional wrapper classes |
|
||||||
|
| `hideHint` | `boolean` | Hide automatic hint |
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Consistency** - All fields follow the same structure
|
||||||
|
2. **Less Code** - ~50% reduction in boilerplate
|
||||||
|
3. **Smart Defaults** - Automatic hints based on field type
|
||||||
|
4. **Built-in Terminology** - Hover tooltips for technical terms
|
||||||
|
5. **Easy Updates** - Change hints in one place, updates everywhere
|
||||||
|
6. **Type Safety** - TypeScript ensures correct usage
|
||||||
384
src/components/ui/form-field-wrapper.tsx
Normal file
384
src/components/ui/form-field-wrapper.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { TermTooltip } from "@/components/ui/term-tooltip";
|
||||||
|
import { fieldHints } from "@/lib/enhancedValidation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field types that automatically get hints and terminology support
|
||||||
|
*/
|
||||||
|
export type FormFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'number'
|
||||||
|
| 'url'
|
||||||
|
| 'email'
|
||||||
|
| 'phone'
|
||||||
|
| 'textarea'
|
||||||
|
| 'slug'
|
||||||
|
| 'height-requirement'
|
||||||
|
| 'age-requirement'
|
||||||
|
| 'capacity'
|
||||||
|
| 'duration'
|
||||||
|
| 'speed'
|
||||||
|
| 'height'
|
||||||
|
| 'length'
|
||||||
|
| 'inversions'
|
||||||
|
| 'g-force'
|
||||||
|
| 'source-url'
|
||||||
|
| 'submission-notes';
|
||||||
|
|
||||||
|
interface FormFieldWrapperProps {
|
||||||
|
/** Field identifier */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Field label text */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Field type - determines automatic hints and validation */
|
||||||
|
fieldType?: FormFieldType;
|
||||||
|
|
||||||
|
/** Terminology key for tooltip (e.g., 'lsm', 'rmc') */
|
||||||
|
termKey?: string;
|
||||||
|
|
||||||
|
/** Show tooltip icon on label */
|
||||||
|
showTermIcon?: boolean;
|
||||||
|
|
||||||
|
/** Whether field is required */
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/** Whether field is optional (shows badge) */
|
||||||
|
optional?: boolean;
|
||||||
|
|
||||||
|
/** Custom hint text (overrides automatic hint) */
|
||||||
|
hint?: string;
|
||||||
|
|
||||||
|
/** Error message from validation (pass errors.field?.message) */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** Current value for character counting */
|
||||||
|
value?: string | number;
|
||||||
|
|
||||||
|
/** Maximum length for character counting */
|
||||||
|
maxLength?: number;
|
||||||
|
|
||||||
|
/** Input props to pass through */
|
||||||
|
inputProps?: React.ComponentProps<typeof Input>;
|
||||||
|
|
||||||
|
/** Textarea props to pass through (when fieldType is 'textarea') */
|
||||||
|
textareaProps?: React.ComponentProps<typeof Textarea>;
|
||||||
|
|
||||||
|
/** Additional className for wrapper */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Hide automatic hint */
|
||||||
|
hideHint?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get automatic hint based on field type
|
||||||
|
*/
|
||||||
|
function getAutoHint(fieldType?: FormFieldType): string | undefined {
|
||||||
|
if (!fieldType) return undefined;
|
||||||
|
|
||||||
|
const hintMap: Record<FormFieldType, string | undefined> = {
|
||||||
|
'text': undefined,
|
||||||
|
'number': undefined,
|
||||||
|
'url': fieldHints.websiteUrl,
|
||||||
|
'email': fieldHints.email,
|
||||||
|
'phone': fieldHints.phone,
|
||||||
|
'textarea': undefined,
|
||||||
|
'slug': fieldHints.slug,
|
||||||
|
'height-requirement': fieldHints.heightRequirement,
|
||||||
|
'age-requirement': fieldHints.ageRequirement,
|
||||||
|
'capacity': fieldHints.capacity,
|
||||||
|
'duration': fieldHints.duration,
|
||||||
|
'speed': fieldHints.speed,
|
||||||
|
'height': fieldHints.height,
|
||||||
|
'length': fieldHints.length,
|
||||||
|
'inversions': fieldHints.inversions,
|
||||||
|
'g-force': fieldHints.gForce,
|
||||||
|
'source-url': fieldHints.sourceUrl,
|
||||||
|
'submission-notes': fieldHints.submissionNotes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return hintMap[fieldType];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input type from field type
|
||||||
|
*/
|
||||||
|
function getInputType(fieldType?: FormFieldType): string {
|
||||||
|
if (!fieldType) return 'text';
|
||||||
|
|
||||||
|
const typeMap: Record<FormFieldType, string> = {
|
||||||
|
'text': 'text',
|
||||||
|
'number': 'number',
|
||||||
|
'url': 'url',
|
||||||
|
'email': 'email',
|
||||||
|
'phone': 'tel',
|
||||||
|
'textarea': 'text',
|
||||||
|
'slug': 'text',
|
||||||
|
'height-requirement': 'number',
|
||||||
|
'age-requirement': 'number',
|
||||||
|
'capacity': 'number',
|
||||||
|
'duration': 'number',
|
||||||
|
'speed': 'number',
|
||||||
|
'height': 'number',
|
||||||
|
'length': 'number',
|
||||||
|
'inversions': 'number',
|
||||||
|
'g-force': 'number',
|
||||||
|
'source-url': 'url',
|
||||||
|
'submission-notes': 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
return typeMap[fieldType] || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified form field wrapper with automatic hints, validation, and terminology
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="website_url"
|
||||||
|
* label="Website URL"
|
||||||
|
* fieldType="url"
|
||||||
|
* error={errors.website_url?.message}
|
||||||
|
* inputProps={{...register('website_url'), placeholder: "https://..."}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With terminology tooltip
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="propulsion"
|
||||||
|
* label="Propulsion Method"
|
||||||
|
* fieldType="text"
|
||||||
|
* termKey="lsm"
|
||||||
|
* hint="Common: LSM Launch, Chain Lift, Hydraulic Launch"
|
||||||
|
* inputProps={{...register('propulsion')}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Textarea with character count
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="notes"
|
||||||
|
* label="Notes"
|
||||||
|
* fieldType="submission-notes"
|
||||||
|
* optional
|
||||||
|
* value={watch('notes')}
|
||||||
|
* maxLength={1000}
|
||||||
|
* textareaProps={{...register('notes'), rows: 3}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FormFieldWrapper({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
fieldType,
|
||||||
|
termKey,
|
||||||
|
showTermIcon = true,
|
||||||
|
required = false,
|
||||||
|
optional = false,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
value,
|
||||||
|
maxLength,
|
||||||
|
inputProps,
|
||||||
|
textareaProps,
|
||||||
|
className,
|
||||||
|
hideHint = false,
|
||||||
|
}: FormFieldWrapperProps) {
|
||||||
|
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
||||||
|
const autoHint = getAutoHint(fieldType);
|
||||||
|
const displayHint = hint || autoHint;
|
||||||
|
const inputType = getInputType(fieldType);
|
||||||
|
|
||||||
|
// Character count for textareas with maxLength
|
||||||
|
const showCharCount = isTextarea && maxLength && typeof value === 'string';
|
||||||
|
const charCount = typeof value === 'string' ? value.length : 0;
|
||||||
|
|
||||||
|
// Determine validation state
|
||||||
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
|
const isValid = !error && hasValue;
|
||||||
|
const hasError = !!error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{/* Label with optional terminology tooltip */}
|
||||||
|
<Label htmlFor={id} className="flex items-center gap-2">
|
||||||
|
{termKey ? (
|
||||||
|
<TermTooltip term={termKey} showIcon={showTermIcon}>
|
||||||
|
{label}
|
||||||
|
</TermTooltip>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{required && <span className="text-destructive">*</span>}
|
||||||
|
{optional && (
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* Input or Textarea with validation icons */}
|
||||||
|
<div className="relative">
|
||||||
|
{isTextarea ? (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"pr-10",
|
||||||
|
error && "border-destructive",
|
||||||
|
isValid && "border-green-500/50"
|
||||||
|
)}
|
||||||
|
maxLength={maxLength}
|
||||||
|
{...textareaProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={inputType}
|
||||||
|
className={cn(
|
||||||
|
"pr-10",
|
||||||
|
error && "border-destructive",
|
||||||
|
isValid && "border-green-500/50"
|
||||||
|
)}
|
||||||
|
maxLength={maxLength}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation icon */}
|
||||||
|
{(isValid || hasError) && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{isValid && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
{hasError && (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint text (if not hidden and exists) */}
|
||||||
|
{!hideHint && displayHint && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{displayHint}
|
||||||
|
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Character count only (when no hint) */}
|
||||||
|
{!hideHint && !displayHint && showCharCount && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{charCount}/{maxLength} characters
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
{showCharCount && ` (${charCount}/${maxLength})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset configurations for common field types
|
||||||
|
*/
|
||||||
|
export const formFieldPresets = {
|
||||||
|
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'url' as FormFieldType,
|
||||||
|
label: 'Website URL',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
email: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'email' as FormFieldType,
|
||||||
|
label: 'Email',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
phone: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'phone' as FormFieldType,
|
||||||
|
label: 'Phone Number',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
sourceUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'source-url' as FormFieldType,
|
||||||
|
label: 'Source URL',
|
||||||
|
optional: true,
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
submissionNotes: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'submission-notes' as FormFieldType,
|
||||||
|
label: 'Notes for Reviewers',
|
||||||
|
optional: true,
|
||||||
|
maxLength: 1000,
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
heightRequirement: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'height-requirement' as FormFieldType,
|
||||||
|
label: 'Height Requirement',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
capacity: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'capacity' as FormFieldType,
|
||||||
|
label: 'Capacity per Hour',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
duration: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'duration' as FormFieldType,
|
||||||
|
label: 'Duration (seconds)',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
speed: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'speed' as FormFieldType,
|
||||||
|
label: 'Max Speed',
|
||||||
|
termKey: 'kilometers-per-hour',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
height: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'height' as FormFieldType,
|
||||||
|
label: 'Max Height',
|
||||||
|
termKey: 'meters',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
length: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'length' as FormFieldType,
|
||||||
|
label: 'Track Length',
|
||||||
|
termKey: 'meters',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
inversions: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'inversions' as FormFieldType,
|
||||||
|
label: 'Inversions',
|
||||||
|
termKey: 'inversion',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
gForce: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'g-force' as FormFieldType,
|
||||||
|
label: 'Max G-Force',
|
||||||
|
termKey: 'g-force',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
};
|
||||||
56
src/components/ui/term-tooltip.tsx
Normal file
56
src/components/ui/term-tooltip.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getGlossaryTerm } from "@/lib/glossary";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TermTooltipProps {
|
||||||
|
term: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inline?: boolean;
|
||||||
|
showIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermTooltip({ term, children, inline = false, showIcon = true }: TermTooltipProps) {
|
||||||
|
const glossaryEntry = getGlossaryTerm(term);
|
||||||
|
|
||||||
|
if (!glossaryEntry) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
inline && "underline decoration-dotted cursor-help"
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
{showIcon && (
|
||||||
|
<HelpCircle className="inline-block w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs" side="top">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-semibold text-sm">{glossaryEntry.term}</div>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{glossaryEntry.category.replace('-', ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{glossaryEntry.definition}</p>
|
||||||
|
{glossaryEntry.example && (
|
||||||
|
<p className="text-xs text-muted-foreground italic pt-1">
|
||||||
|
Example: {glossaryEntry.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{glossaryEntry.relatedTerms && glossaryEntry.relatedTerms.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">
|
||||||
|
See also: {glossaryEntry.relatedTerms.map(t =>
|
||||||
|
getGlossaryTerm(t)?.term || t
|
||||||
|
).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/ui/validated-input.tsx
Normal file
87
src/components/ui/validated-input.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Check, AlertCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
export interface ValidatedInputProps extends React.ComponentProps<typeof Input> {
|
||||||
|
validation?: {
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
showValidation?: boolean;
|
||||||
|
onValidate?: (value: string) => { isValid: boolean; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidatedInput = React.forwardRef<HTMLInputElement, ValidatedInputProps>(
|
||||||
|
({ className, validation, showValidation = true, onValidate, onChange, ...props }, ref) => {
|
||||||
|
const [localValidation, setLocalValidation] = React.useState<{
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const debouncedValidate = useDebouncedCallback((value: string) => {
|
||||||
|
if (onValidate) {
|
||||||
|
const result = onValidate(value);
|
||||||
|
setLocalValidation(result);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e);
|
||||||
|
if (onValidate && showValidation) {
|
||||||
|
debouncedValidate(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationState = validation || localValidation;
|
||||||
|
const showSuccess = showValidation && validationState.isValid && props.value;
|
||||||
|
const showError = showValidation && validationState.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
showError && "border-destructive focus-visible:ring-destructive",
|
||||||
|
showSuccess && "border-green-500 focus-visible:ring-green-500",
|
||||||
|
"pr-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{showValidation && props.value && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
{validationState.isValid && (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
{validationState.error && (
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showValidation && validation?.hint && !validationState.error && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{validation.hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showError && (
|
||||||
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{validationState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ValidatedInput.displayName = "ValidatedInput";
|
||||||
|
|
||||||
|
export { ValidatedInput };
|
||||||
171
src/lib/enhancedValidation.ts
Normal file
171
src/lib/enhancedValidation.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Validation Messages
|
||||||
|
* Provides contextual, helpful error messages with examples
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const validationMessages = {
|
||||||
|
slug: {
|
||||||
|
format: 'Slug must contain only lowercase letters, numbers, and hyphens. Example: "steel-vengeance" or "millennium-force"',
|
||||||
|
required: 'Slug is required. It will be used in the URL. Example: "fury-325"',
|
||||||
|
duplicate: 'This slug is already in use. Try adding a location or number: "thunder-run-kentucky"',
|
||||||
|
},
|
||||||
|
|
||||||
|
url: {
|
||||||
|
format: 'Must be a valid URL starting with http:// or https://. Example: "https://www.cedarpoint.com"',
|
||||||
|
protocol: 'URL must start with http:// or https://. Add the protocol to your URL.',
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
format: 'Must be a valid email address. Example: "contact@park.com"',
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: {
|
||||||
|
format: 'Enter phone number in any format. Examples: "+1-419-555-0123" or "(419) 555-0123"',
|
||||||
|
maxLength: (max: number) => `Phone number must be less than ${max} characters`,
|
||||||
|
},
|
||||||
|
|
||||||
|
dates: {
|
||||||
|
future: 'Opening date cannot be in the future. Use today or an earlier date.',
|
||||||
|
closingBeforeOpening: 'Closing date must be after opening date. Check both dates for accuracy.',
|
||||||
|
invalidFormat: 'Invalid date format. Use the date picker or enter in YYYY-MM-DD format.',
|
||||||
|
precision: 'Select how precise this date is (exact, month, year, etc.)',
|
||||||
|
},
|
||||||
|
|
||||||
|
numbers: {
|
||||||
|
heightRequirement: 'Height must be in centimeters, between 0-300. Example: "122" for 122cm (48 inches)',
|
||||||
|
speed: 'Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)',
|
||||||
|
length: 'Length must be in meters. Example: "1981" for 1,981 meters (6,500 feet)',
|
||||||
|
height: 'Height must be in meters. Example: "94" for 94 meters (310 feet)',
|
||||||
|
gForce: 'G-force must be between -10 and 10. Example: "4.5" for 4.5 positive Gs',
|
||||||
|
inversions: 'Number of inversions (upside-down elements). Example: "7"',
|
||||||
|
capacity: 'Capacity per hour must be between 1-99,999. Example: "1200" for 1,200 riders/hour',
|
||||||
|
duration: 'Duration in seconds. Example: "180" for 3 minutes',
|
||||||
|
positive: 'Value must be a positive number',
|
||||||
|
range: (min: number, max: number) => `Value must be between ${min} and ${max}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
text: {
|
||||||
|
required: 'This field is required',
|
||||||
|
maxLength: (max: number, current?: number) =>
|
||||||
|
current ? `${current}/${max} characters. Please shorten by ${current - max} characters.` : `Maximum ${max} characters`,
|
||||||
|
minLength: (min: number) => `Must be at least ${min} characters`,
|
||||||
|
noHtml: 'HTML tags are not allowed. Use plain text only.',
|
||||||
|
trimmed: 'Extra spaces at the beginning or end will be removed',
|
||||||
|
},
|
||||||
|
|
||||||
|
park: {
|
||||||
|
nameRequired: 'Park name is required. Example: "Cedar Point" or "Six Flags Magic Mountain"',
|
||||||
|
typeRequired: 'Select a park type (theme park, amusement park, water park, etc.)',
|
||||||
|
statusRequired: 'Select the current status (operating, closed, under construction, etc.)',
|
||||||
|
locationRequired: 'Location is required. Use the search to find or add a location.',
|
||||||
|
operatorHelp: 'The company that operates the park (e.g., Cedar Fair, Six Flags)',
|
||||||
|
ownerHelp: 'The company that owns the property (often same as operator)',
|
||||||
|
},
|
||||||
|
|
||||||
|
ride: {
|
||||||
|
nameRequired: 'Ride name is required. Example: "Steel Vengeance" or "Maverick"',
|
||||||
|
categoryRequired: 'Select a ride category (roller coaster, flat ride, water ride, etc.)',
|
||||||
|
parkRequired: 'Park is required. Select or create the park where this ride is located.',
|
||||||
|
manufacturerHelp: 'Company that manufactured the ride (e.g., RMC, Intamin, B&M)',
|
||||||
|
designerHelp: 'Company that designed the ride (if different from manufacturer)',
|
||||||
|
trackMaterial: 'Materials used for the track. Common: Steel, Wood, Hybrid (RMC IBox)',
|
||||||
|
supportMaterial: 'Materials used for support structure. Common: Steel, Wood',
|
||||||
|
propulsionMethod: 'How the ride is propelled. Common: LSM Launch, Chain Lift, Hydraulic Launch',
|
||||||
|
},
|
||||||
|
|
||||||
|
company: {
|
||||||
|
nameRequired: 'Company name is required. Example: "Rocky Mountain Construction"',
|
||||||
|
typeRequired: 'Select company type (manufacturer, designer, operator, property owner)',
|
||||||
|
countryHelp: 'Country where the company is headquartered',
|
||||||
|
},
|
||||||
|
|
||||||
|
units: {
|
||||||
|
metricOnly: 'All measurements must be in metric units (m, km, cm, kg, km/h, etc.)',
|
||||||
|
metricExamples: 'Use metric: m (meters), km/h (speed), cm (centimeters), kg (weight)',
|
||||||
|
imperialNote: 'The system will automatically convert to imperial for users who prefer it',
|
||||||
|
temperature: 'Temperature must be in Celsius. Example: "25" for 25°C (77°F)',
|
||||||
|
},
|
||||||
|
|
||||||
|
submission: {
|
||||||
|
sourceUrl: 'Where did you find this information? Helps moderators verify accuracy. Example: manufacturer website, news article, park map',
|
||||||
|
notes: 'Add context for moderators. Example: "Confirmed via park press release" or "Specifications approximate"',
|
||||||
|
notesMaxLength: 'Submission notes must be less than 1000 characters',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common validation helpers
|
||||||
|
*/
|
||||||
|
export const validationHelpers = {
|
||||||
|
/**
|
||||||
|
* Check if a URL has proper protocol
|
||||||
|
*/
|
||||||
|
hasProtocol: (url: string): boolean => {
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest adding protocol to URL
|
||||||
|
*/
|
||||||
|
suggestProtocol: (url: string): string => {
|
||||||
|
if (!url) return '';
|
||||||
|
if (validationHelpers.hasProtocol(url)) return url;
|
||||||
|
return `https://${url}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a slug from a name
|
||||||
|
*/
|
||||||
|
formatSlug: (name: string): string => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is in the future
|
||||||
|
*/
|
||||||
|
isFutureDate: (date: string | Date): boolean => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d > new Date();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format character count display
|
||||||
|
*/
|
||||||
|
formatCharCount: (current: number, max: number): string => {
|
||||||
|
const remaining = max - current;
|
||||||
|
if (remaining < 0) {
|
||||||
|
return `${current}/${max} (${Math.abs(remaining)} over limit)`;
|
||||||
|
}
|
||||||
|
if (remaining < 50) {
|
||||||
|
return `${current}/${max} (${remaining} remaining)`;
|
||||||
|
}
|
||||||
|
return `${current}/${max}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-specific validation hints for FormDescription
|
||||||
|
*/
|
||||||
|
export const fieldHints = {
|
||||||
|
slug: 'URL-friendly identifier using lowercase letters, numbers, and hyphens only',
|
||||||
|
websiteUrl: 'Official website URL (must start with https:// or http://)',
|
||||||
|
email: 'Contact email for the park or ride operator',
|
||||||
|
phone: 'Contact phone number in any format',
|
||||||
|
heightRequirement: 'Minimum height in centimeters (metric). Will be converted for display.',
|
||||||
|
ageRequirement: 'Minimum age requirement in years',
|
||||||
|
capacity: 'Theoretical maximum riders per hour under optimal conditions',
|
||||||
|
duration: 'Typical ride duration in seconds from dispatch to return',
|
||||||
|
speed: 'Maximum speed in km/h (metric). Will be converted for display.',
|
||||||
|
height: 'Maximum height in meters (metric). Will be converted for display.',
|
||||||
|
length: 'Track/route length in meters (metric). Will be converted for display.',
|
||||||
|
inversions: 'Total number of elements where riders go upside down (≥90 degrees)',
|
||||||
|
gForce: 'Maximum positive or negative G-forces experienced',
|
||||||
|
sourceUrl: 'Reference link to verify this information (Wikipedia, official site, news article, etc.)',
|
||||||
|
submissionNotes: 'Help moderators understand your submission (how you verified the info, any uncertainties, etc.)',
|
||||||
|
};
|
||||||
390
src/lib/glossary.ts
Normal file
390
src/lib/glossary.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Theme Park Terminology Glossary
|
||||||
|
* Comprehensive definitions for technical terms used in forms
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GlossaryTerm {
|
||||||
|
term: string;
|
||||||
|
category: 'manufacturer' | 'technology' | 'element' | 'component' | 'measurement' | 'type' | 'material';
|
||||||
|
definition: string;
|
||||||
|
example?: string;
|
||||||
|
relatedTerms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const glossary: Record<string, GlossaryTerm> = {
|
||||||
|
// Manufacturers
|
||||||
|
'rmc': {
|
||||||
|
term: 'RMC',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Rocky Mountain Construction - Manufacturer known for hybrid coasters with steel IBox track on wooden structures',
|
||||||
|
example: 'Steel Vengeance at Cedar Point',
|
||||||
|
relatedTerms: ['ibox-track', 'hybrid-coaster'],
|
||||||
|
},
|
||||||
|
'intamin': {
|
||||||
|
term: 'Intamin',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Swiss manufacturer known for record-breaking coasters and innovative launch systems',
|
||||||
|
example: 'Millennium Force, Top Thrill Dragster',
|
||||||
|
relatedTerms: ['hydraulic-launch', 'lsm'],
|
||||||
|
},
|
||||||
|
'b&m': {
|
||||||
|
term: 'B&M',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Bolliger & Mabillard - Swiss manufacturer known for smooth, reliable coasters',
|
||||||
|
example: 'Fury 325, Banshee, GateKeeper',
|
||||||
|
relatedTerms: ['inverted', 'wing-coaster', 'dive-coaster'],
|
||||||
|
},
|
||||||
|
'vekoma': {
|
||||||
|
term: 'Vekoma',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Dutch manufacturer with wide range from family coasters to intense thrill rides',
|
||||||
|
example: 'Space Mountain (Disney), Thunderbird (PowerPark)',
|
||||||
|
},
|
||||||
|
'gerstlauer': {
|
||||||
|
term: 'Gerstlauer',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'German manufacturer known for compact, intense coasters with vertical lifts',
|
||||||
|
example: 'Takabisha (steepest drop), Karacho',
|
||||||
|
relatedTerms: ['euro-fighter'],
|
||||||
|
},
|
||||||
|
's&s': {
|
||||||
|
term: 'S&S',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'S&S Worldwide - American manufacturer of compressed-air launch coasters and thrill rides',
|
||||||
|
example: 'Hypersonic XLC, Screamin\' Swing',
|
||||||
|
relatedTerms: ['compressed-air-launch'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Launch/Propulsion Systems
|
||||||
|
'lsm': {
|
||||||
|
term: 'LSM Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Linear Synchronous Motor - Uses electromagnetic propulsion to smoothly accelerate trains',
|
||||||
|
example: 'Maverick, Taron, Velocicoaster',
|
||||||
|
relatedTerms: ['lim', 'magnetic-launch'],
|
||||||
|
},
|
||||||
|
'lim': {
|
||||||
|
term: 'LIM Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Linear Induction Motor - Earlier electromagnetic launch technology, less efficient than LSM',
|
||||||
|
example: 'Flight of Fear, Rock \'n\' Roller Coaster',
|
||||||
|
relatedTerms: ['lsm'],
|
||||||
|
},
|
||||||
|
'hydraulic-launch': {
|
||||||
|
term: 'Hydraulic Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses hydraulic winch system to rapidly accelerate train, capable of extreme speeds',
|
||||||
|
example: 'Top Thrill Dragster, Kingda Ka (fastest launches)',
|
||||||
|
relatedTerms: ['intamin'],
|
||||||
|
},
|
||||||
|
'chain-lift': {
|
||||||
|
term: 'Chain Lift',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Traditional lift system using chain and anti-rollback dogs',
|
||||||
|
example: 'Most traditional wooden and steel coasters',
|
||||||
|
},
|
||||||
|
'cable-lift': {
|
||||||
|
term: 'Cable Lift',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses steel cable for faster lift speeds than chain',
|
||||||
|
example: 'Millennium Force (first major use)',
|
||||||
|
},
|
||||||
|
'compressed-air-launch': {
|
||||||
|
term: 'Compressed Air Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses compressed air to launch train, very powerful acceleration',
|
||||||
|
example: 'Hypersonic XLC, Do-Dodonpa',
|
||||||
|
relatedTerms: ['s&s'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Coaster Types
|
||||||
|
'inverted': {
|
||||||
|
term: 'Inverted Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Train runs below the track with feet dangling, track above riders',
|
||||||
|
example: 'Banshee, Montu, Raptor',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'wing-coaster': {
|
||||||
|
term: 'Wing Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Seats extend to sides of track with nothing above or below riders',
|
||||||
|
example: 'GateKeeper, The Swarm, X-Flight',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'dive-coaster': {
|
||||||
|
term: 'Dive Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Features wide trains and vertical/near-vertical first drop, often with holding brake',
|
||||||
|
example: 'Valravn, SheiKra, Griffon',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'flying-coaster': {
|
||||||
|
term: 'Flying Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Riders positioned face-down in flying position',
|
||||||
|
example: 'Tatsu, Manta, Flying Dinosaur',
|
||||||
|
relatedTerms: ['b&m', 'vekoma'],
|
||||||
|
},
|
||||||
|
'hyper-coaster': {
|
||||||
|
term: 'Hyper Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster between 200-299 feet tall, focused on airtime',
|
||||||
|
example: 'Diamondback, Nitro, Apollo\'s Chariot',
|
||||||
|
relatedTerms: ['giga-coaster', 'airtime'],
|
||||||
|
},
|
||||||
|
'giga-coaster': {
|
||||||
|
term: 'Giga Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster between 300-399 feet tall',
|
||||||
|
example: 'Millennium Force, Fury 325, Leviathan',
|
||||||
|
relatedTerms: ['hyper-coaster', 'strata-coaster'],
|
||||||
|
},
|
||||||
|
'strata-coaster': {
|
||||||
|
term: 'Strata Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster 400+ feet tall',
|
||||||
|
example: 'Top Thrill Dragster, Kingda Ka',
|
||||||
|
relatedTerms: ['giga-coaster'],
|
||||||
|
},
|
||||||
|
'hybrid-coaster': {
|
||||||
|
term: 'Hybrid Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Steel track on wooden support structure',
|
||||||
|
example: 'Steel Vengeance, Twisted Colossus',
|
||||||
|
relatedTerms: ['rmc', 'ibox-track'],
|
||||||
|
},
|
||||||
|
'euro-fighter': {
|
||||||
|
term: 'Euro-Fighter',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Compact Gerstlauer coaster with vertical lift and beyond-vertical drop',
|
||||||
|
example: 'Takabisha, Saw: The Ride',
|
||||||
|
relatedTerms: ['gerstlauer'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Track Materials
|
||||||
|
'ibox-track': {
|
||||||
|
term: 'IBox Track',
|
||||||
|
category: 'material',
|
||||||
|
definition: 'RMC\'s steel box-beam track system used on hybrid coasters, allows extreme elements',
|
||||||
|
example: 'Steel Vengeance, Iron Rattler',
|
||||||
|
relatedTerms: ['rmc', 'hybrid-coaster', 'topper-track'],
|
||||||
|
},
|
||||||
|
'topper-track': {
|
||||||
|
term: 'Topper Track',
|
||||||
|
category: 'material',
|
||||||
|
definition: 'RMC\'s steel plate topper on wooden track for smoother wooden coaster experience',
|
||||||
|
example: 'Outlaw Run, Lightning Rod',
|
||||||
|
relatedTerms: ['rmc', 'ibox-track'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restraint Types
|
||||||
|
'otsr': {
|
||||||
|
term: 'OTSR',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Over-The-Shoulder Restraint - Safety harness that goes over shoulders and locks at waist',
|
||||||
|
example: 'Used on most inverting coasters',
|
||||||
|
relatedTerms: ['vest-restraint', 'lap-bar'],
|
||||||
|
},
|
||||||
|
'lap-bar': {
|
||||||
|
term: 'Lap Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Restraint that only crosses the lap/thighs, offers more freedom',
|
||||||
|
example: 'Millennium Force, most airtime-focused rides',
|
||||||
|
relatedTerms: ['otsr', 't-bar'],
|
||||||
|
},
|
||||||
|
't-bar': {
|
||||||
|
term: 'T-Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'T-shaped lap bar restraint, common on Intamin hyper coasters',
|
||||||
|
example: 'Intimidator 305, Skyrush',
|
||||||
|
relatedTerms: ['lap-bar'],
|
||||||
|
},
|
||||||
|
'vest-restraint': {
|
||||||
|
term: 'Vest Restraint',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Soft vest-style over-shoulder restraint, more comfortable than traditional OTSR',
|
||||||
|
example: 'GateKeeper, Valravn (B&M)',
|
||||||
|
relatedTerms: ['otsr'],
|
||||||
|
},
|
||||||
|
'shin-bar': {
|
||||||
|
term: 'Shin Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Additional restraint that holds shins in place, used on some intense rides',
|
||||||
|
example: 'Flying coasters, some Vekoma rides',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
'airtime': {
|
||||||
|
term: 'Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Negative G-forces that create sensation of floating or being lifted from seat',
|
||||||
|
example: 'Camelback hills, speed hills',
|
||||||
|
relatedTerms: ['ejector-airtime', 'floater-airtime', 'hangtime'],
|
||||||
|
},
|
||||||
|
'ejector-airtime': {
|
||||||
|
term: 'Ejector Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Strong negative Gs that forcefully lift riders from seats',
|
||||||
|
example: 'El Toro, Skyrush airtime hills',
|
||||||
|
relatedTerms: ['airtime'],
|
||||||
|
},
|
||||||
|
'floater-airtime': {
|
||||||
|
term: 'Floater Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Gentle negative Gs that create sustained floating sensation',
|
||||||
|
example: 'B&M hyper coasters',
|
||||||
|
relatedTerms: ['airtime'],
|
||||||
|
},
|
||||||
|
'hangtime': {
|
||||||
|
term: 'Hangtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Suspension in mid-air during inversion, typically at apex of element',
|
||||||
|
example: 'Zero-g rolls, inversions on dive coasters',
|
||||||
|
relatedTerms: ['airtime', 'inversion'],
|
||||||
|
},
|
||||||
|
'inversion': {
|
||||||
|
term: 'Inversion',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Element where riders are turned upside down (≥90 degrees from upright)',
|
||||||
|
example: 'Loops, corkscrews, barrel rolls',
|
||||||
|
relatedTerms: ['zero-g-roll', 'corkscrew', 'loop'],
|
||||||
|
},
|
||||||
|
'zero-g-roll': {
|
||||||
|
term: 'Zero-G Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Heartline inversion with sustained weightlessness',
|
||||||
|
example: 'Common on Intamin and B&M coasters',
|
||||||
|
relatedTerms: ['inversion', 'hangtime'],
|
||||||
|
},
|
||||||
|
'corkscrew': {
|
||||||
|
term: 'Corkscrew',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Inversion where track twists 360 degrees while moving forward',
|
||||||
|
example: 'Classic Arrow element',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'loop': {
|
||||||
|
term: 'Vertical Loop',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Full 360-degree vertical circle',
|
||||||
|
example: 'Classic clothoid loop shape',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'dive-loop': {
|
||||||
|
term: 'Dive Loop',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Half loop up, half corkscrew down',
|
||||||
|
example: 'Common on B&M coasters',
|
||||||
|
relatedTerms: ['immelmann', 'inversion'],
|
||||||
|
},
|
||||||
|
'immelmann': {
|
||||||
|
term: 'Immelmann',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Half loop up, half roll out (opposite of dive loop)',
|
||||||
|
example: 'Named after WWI pilot maneuver',
|
||||||
|
relatedTerms: ['dive-loop', 'inversion'],
|
||||||
|
},
|
||||||
|
'cobra-roll': {
|
||||||
|
term: 'Cobra Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Double inversion creating S-shape, reversing direction',
|
||||||
|
example: 'Common on Vekoma and B&M loopers',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'heartline-roll': {
|
||||||
|
term: 'Heartline Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Barrel roll rotating around rider\'s heartline for smooth inversion',
|
||||||
|
example: 'Maverick, many Intamin coasters',
|
||||||
|
relatedTerms: ['zero-g-roll', 'inversion'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Technical Terms
|
||||||
|
'mcbr': {
|
||||||
|
term: 'MCBR',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Mid-Course Brake Run - Safety brake zone that divides track into blocks',
|
||||||
|
example: 'Allows multiple trains to operate safely',
|
||||||
|
relatedTerms: ['block-section'],
|
||||||
|
},
|
||||||
|
'block-section': {
|
||||||
|
term: 'Block Section',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Track section that only one train can occupy at a time for safety',
|
||||||
|
example: 'Station, lift hill, brake runs',
|
||||||
|
relatedTerms: ['mcbr'],
|
||||||
|
},
|
||||||
|
'trim-brake': {
|
||||||
|
term: 'Trim Brake',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Brake that slows train slightly to control speed',
|
||||||
|
example: 'Often on hills or before elements',
|
||||||
|
},
|
||||||
|
'transfer-track': {
|
||||||
|
term: 'Transfer Track',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Movable track section for adding/removing trains from circuit',
|
||||||
|
example: 'Allows storage of extra trains',
|
||||||
|
},
|
||||||
|
'anti-rollback': {
|
||||||
|
term: 'Anti-Rollback',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Safety device preventing train from rolling backward on lift',
|
||||||
|
example: 'Creates "clicking" sound on chain lifts',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Measurements
|
||||||
|
'g-force': {
|
||||||
|
term: 'G-Force',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Force of gravity felt by riders. 1G = normal gravity, positive = pushed into seat, negative = lifted from seat',
|
||||||
|
example: '4.5G on intense loops, -1.5G on airtime hills',
|
||||||
|
},
|
||||||
|
'kilometers-per-hour': {
|
||||||
|
term: 'km/h',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Speed measurement in kilometers per hour (metric)',
|
||||||
|
example: '193 km/h = 120 mph',
|
||||||
|
},
|
||||||
|
'meters': {
|
||||||
|
term: 'Meters',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Length/height measurement (metric). 1 meter ≈ 3.28 feet',
|
||||||
|
example: '94 meters = 310 feet',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get glossary term by key (normalized)
|
||||||
|
*/
|
||||||
|
export function getGlossaryTerm(term: string): GlossaryTerm | undefined {
|
||||||
|
const key = term.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||||
|
return glossary[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search glossary by query
|
||||||
|
*/
|
||||||
|
export function searchGlossary(query: string): GlossaryTerm[] {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Object.values(glossary).filter(term =>
|
||||||
|
term.term.toLowerCase().includes(lowerQuery) ||
|
||||||
|
term.definition.toLowerCase().includes(lowerQuery) ||
|
||||||
|
term.example?.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all terms in a category
|
||||||
|
*/
|
||||||
|
export function getTermsByCategory(category: GlossaryTerm['category']): GlossaryTerm[] {
|
||||||
|
return Object.values(glossary).filter(term => term.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all categories
|
||||||
|
*/
|
||||||
|
export function getAllCategories(): GlossaryTerm['category'][] {
|
||||||
|
return ['manufacturer', 'technology', 'element', 'component', 'measurement', 'type', 'material'];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user