Compare commits

..

4 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
7d085a0702 Enhance FormFieldWrapper Validation
Add real-time validation feedback to FormFieldWrapper:
- Manage validation state (idle/valid/invalid)
- Show green check icon when valid
- Show inline error icon when invalid
- Integrate with existing Input/Textarea components without altering core behavior
2025-11-11 23:38:07 +00:00
gpt-engineer-app[bot]
6fef107728 Create unified FormFieldWrapper
Introduce a new reusable form field component that automatically shows hints, validation messages, and terminology tooltips based on field type; refactor forms to demonstrate usage.
2025-11-11 23:34:57 +00:00
gpt-engineer-app[bot]
42f26acb49 Add FormDescription hints across fields
Enhance forms by adding descriptive hints (with examples) to all fields (website URLs, heights, speeds, lengths, etc.) using the existing enhanced validation system. Includes updating ParkForm and RideForm to display field-specific guidance (and field hints for URLs, measurements, and submission notes), leveraging field hints generated from enhancedValidation.
2025-11-11 23:29:10 +00:00
gpt-engineer-app[bot]
985454f0d9 Enhance forms with validation and terminology
Implements enhanced inline validation with contextual messages and examples, adds a comprehensive theme park terminology tool (tooltip and glossary), and integrates these features into ParkForm and RideForm (including header actions and descriptive hints). Also introduces new helper modules and components to support validated inputs and glossary tooltips.
2025-11-11 23:25:15 +00:00
11 changed files with 1813 additions and 45 deletions

View File

@@ -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>
<SubmissionHelpDialog type="park" variant="icon" /> <div className="flex gap-2">
<TerminologyDialog />
<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}>

View File

@@ -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,7 +394,10 @@ 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>
<SubmissionHelpDialog type="ride" variant="icon" /> <div className="flex gap-2">
<TerminologyDialog />
<SubmissionHelpDialog type="ride" variant="icon" />
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -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>

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View 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

View 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,
}),
};

View 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>
);
}

View 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 };

View 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
View 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'];
}