Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

190
src-old/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Shield, Users, FileText, Flag, AlertCircle, RefreshCw } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
import { ReportsQueue, ReportsQueueRef } from '@/components/moderation/ReportsQueue';
import { UserManagement } from '@/components/admin/UserManagement';
import { AdminHeader } from '@/components/layout/AdminHeader';
import { useModerationStats } from '@/hooks/useModerationStats';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function Admin() {
useDocumentTitle('Admin Panel');
const isMobile = useIsMobile();
const { user, loading: authLoading } = useAuth();
const { isModerator, loading: roleLoading } = useUserRole();
const navigate = useNavigate();
const moderationQueueRef = useRef<ModerationQueueRef>(null);
const reportsQueueRef = useRef<ReportsQueueRef>(null);
// Get admin settings for polling configuration
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
isLoading: settingsLoading
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
// Use stats hook with configurable polling
const { stats, refresh: refreshStats, lastUpdated } = useModerationStats({
enabled: !!user && !authLoading && !roleLoading && isModerator(),
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
const handleRefresh = useCallback(() => {
moderationQueueRef.current?.refresh();
reportsQueueRef.current?.refresh();
refreshStats();
}, [refreshStats]);
useEffect(() => {
if (!authLoading && !roleLoading) {
if (!user) {
navigate('/auth');
return;
}
if (!isModerator()) {
navigate('/');
return;
}
}
}, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading admin panel...</p>
</div>
</div>
</div>
);
}
if (!user || !isModerator()) {
return null;
}
return (
<>
<AdminHeader onRefresh={handleRefresh} />
<div className="container mx-auto px-4 py-8">
<div className="space-y-4 mb-8">
{/* Refresh status indicator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4" />
{refreshMode === 'auto' ? (
<span>Auto-refresh: every {pollInterval / 1000}s</span>
) : (
<span>Manual refresh only</span>
)}
{lastUpdated && (
<span className="text-xs">
Last updated: {lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-3 gap-3 md:gap-6">
<Card>
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
<FileText className="h-4 w-4 text-muted-foreground mb-2" />
<CardTitle className="text-sm font-medium">Pending Submissions</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-2xl font-bold">
{stats.pendingSubmissions}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
<Flag className="h-4 w-4 text-muted-foreground mb-2" />
<CardTitle className="text-sm font-medium">Open Reports</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-2xl font-bold">
{stats.openReports}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
<AlertCircle className="h-4 w-4 text-muted-foreground mb-2" />
<CardTitle className="text-sm font-medium">Flagged Content</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-2xl font-bold">
{stats.flaggedContent}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Content Moderation Section */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Moderation Queue
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="queue" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="queue" className="flex items-center gap-2">
<FileText className="w-4 h-4" />
{isMobile ? 'Queue' : 'Moderation Queue'}
</TabsTrigger>
<TabsTrigger value="reports" className="flex items-center gap-2">
<Flag className="w-4 h-4" />
Reports
</TabsTrigger>
</TabsList>
<TabsContent value="queue" forceMount={true}>
<ModerationQueue ref={moderationQueueRef} />
</TabsContent>
<TabsContent value="reports" forceMount={true}>
<ReportsQueue ref={reportsQueueRef} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* User Management Section */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
User Management
</CardTitle>
</CardHeader>
<CardContent>
<UserManagement />
</CardContent>
</Card>
</div>
</>
);
}

382
src-old/pages/AdminBlog.tsx Normal file
View File

@@ -0,0 +1,382 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { UppyPhotoUploadLazy } from '@/components/upload/UppyPhotoUploadLazy';
import { MarkdownEditorLazy } from '@/components/admin/MarkdownEditorLazy';
import { generateSlugFromName } from '@/lib/slugUtils';
import { extractCloudflareImageId } from '@/lib/cloudflareImageUtils';
import { Edit, Trash2, Eye, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
interface BlogPost {
id: string;
slug: string;
title: string;
content: string;
featured_image_id?: string;
featured_image_url?: string;
status: 'draft' | 'published';
published_at?: string;
view_count: number;
created_at: string;
}
export default function AdminBlog() {
useDocumentTitle('Blog Management - Admin');
const { user } = useAuth();
const { isAdmin, loading } = useUserRole();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('posts');
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [content, setContent] = useState('');
const [featuredImageId, setFeaturedImageId] = useState('');
const [featuredImageUrl, setFeaturedImageUrl] = useState('');
// All mutations must be called before conditional returns
const saveMutation = useMutation({
mutationFn: async ({ isDraft }: { isDraft: boolean }) => {
const postData = {
title,
slug,
content,
featured_image_id: featuredImageId || null,
featured_image_url: featuredImageUrl || null,
author_id: user!.id,
status: isDraft ? 'draft' : 'published',
published_at: isDraft ? null : new Date().toISOString(),
};
if (editingPost) {
const { error } = await supabase
.from('blog_posts')
.update(postData)
.eq('id', editingPost.id);
if (error) throw error;
} else {
const { error } = await supabase
.from('blog_posts')
.insert(postData);
if (error) throw error;
}
},
onSuccess: (_, { isDraft }) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
toast.success(isDraft ? 'Draft saved' : 'Post published');
resetForm();
setActiveTab('posts');
},
onError: (error) => {
toast.error('Failed to save post: ' + error.message);
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase
.from('blog_posts')
.delete()
.eq('id', id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
toast.success('Post deleted');
},
onError: (error) => {
toast.error('Failed to delete post: ' + error.message);
},
});
const { data: posts, isLoading } = useQuery({
queryKey: ['admin-blog-posts'],
queryFn: async () => {
const { data, error } = await supabase
.from('blog_posts')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data as BlogPost[];
},
});
// useEffect must be called before conditional returns
useEffect(() => {
if (!loading && !isAdmin()) {
navigate('/');
}
}, [loading, isAdmin, navigate]);
// Show loading state while checking permissions
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</AdminLayout>
);
}
// Don't render if not admin
if (!loading && !isAdmin()) {
return null;
}
const resetForm = () => {
setTitle('');
setSlug('');
setContent('');
setFeaturedImageId('');
setFeaturedImageUrl('');
setEditingPost(null);
};
const loadPostForEditing = (post: BlogPost) => {
setEditingPost(post);
setTitle(post.title);
setSlug(post.slug);
setContent(post.content);
setFeaturedImageId(post.featured_image_id || '');
setFeaturedImageUrl(post.featured_image_url || '');
setActiveTab('editor');
};
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
if (!editingPost) {
setSlug(generateSlugFromName(newTitle));
}
};
const handleImageUpload = (imageId: string, imageUrl: string) => {
setFeaturedImageId(imageId);
setFeaturedImageUrl(imageUrl);
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Blog Management</h1>
<p className="text-muted-foreground">Create and manage blog posts</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="posts">Posts</TabsTrigger>
<TabsTrigger value="editor">
{editingPost ? 'Edit Post' : 'New Post'}
</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{posts?.length || 0} total posts
</p>
<Button onClick={() => {
resetForm();
setActiveTab('editor');
}}>
<Plus className="w-4 h-4 mr-2" />
New Post
</Button>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Published</TableHead>
<TableHead>Views</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
Loading...
</TableCell>
</TableRow>
) : posts?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
No posts yet. Create your first post!
</TableCell>
</TableRow>
) : (
posts?.map((post) => (
<TableRow key={post.id}>
<TableCell className="font-medium">
{post.title}
</TableCell>
<TableCell>
<Badge variant={post.status === 'published' ? 'default' : 'secondary'}>
{post.status}
</Badge>
</TableCell>
<TableCell>
{post.published_at
? formatDistanceToNow(new Date(post.published_at), { addSuffix: true })
: '-'
}
</TableCell>
<TableCell>{post.view_count}</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => window.open(`/blog/${post.slug}`, '_blank')}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => loadPostForEditing(post)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Delete this post?')) {
deleteMutation.mutate(post.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</TabsContent>
<TabsContent value="editor" className="space-y-6">
<Card className="p-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Enter post title..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="post-url-slug"
/>
<p className="text-xs text-muted-foreground">
URL: /blog/{slug || 'post-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label>Featured Image</Label>
<UppyPhotoUploadLazy
onUploadComplete={(urls) => {
if (urls.length > 0) {
const url = urls[0];
const imageId = extractCloudflareImageId(url);
if (imageId) {
handleImageUpload(imageId, url);
toast.success('Image uploaded');
}
}
}}
maxFiles={1}
/>
{featuredImageUrl && (
<img
src={featuredImageUrl}
alt="Preview"
className="w-full max-w-md rounded-lg"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="content">Content (Markdown) *</Label>
<MarkdownEditorLazy
value={content}
onChange={setContent}
onSave={async (value) => {
if (editingPost) {
await supabase
.from('blog_posts')
.update({ content: value, updated_at: new Date().toISOString() })
.eq('id', editingPost.id);
}
}}
autoSave={!!editingPost}
height={600}
placeholder="Write your blog post in markdown..."
/>
</div>
<div className="flex gap-3">
<Button
onClick={() => saveMutation.mutate({ isDraft: true })}
disabled={!title || !slug || !content || saveMutation.isPending}
variant="outline"
>
Save Draft
</Button>
<Button
onClick={() => saveMutation.mutate({ isDraft: false })}
disabled={!title || !slug || !content || saveMutation.isPending}
>
Publish
</Button>
<Button
variant="ghost"
onClick={() => {
resetForm();
setActiveTab('posts');
}}
>
Cancel
</Button>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,305 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Flag, AlertCircle, Activity, ShieldAlert } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
import { ReportsQueue } from '@/components/moderation/ReportsQueue';
import { RecentActivity } from '@/components/moderation/RecentActivity';
import { useModerationStats } from '@/hooks/useModerationStats';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { supabase } from '@/lib/supabaseClient';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminDashboard() {
useDocumentTitle('Dashboard - Admin');
const { user, loading: authLoading } = useAuth();
const { isModerator, loading: roleLoading } = useUserRole();
const { needsEnrollment, loading: mfaLoading } = useRequireMFA();
const navigate = useNavigate();
const [isRefreshing, setIsRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState('moderation');
const [suspiciousVersionsCount, setSuspiciousVersionsCount] = useState<number>(0);
const moderationQueueRef = useRef<ModerationQueueRef>(null);
const reportsQueueRef = useRef<any>(null);
const recentActivityRef = useRef<any>(null);
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
const { stats, refresh: refreshStats, optimisticallyUpdateStats, lastUpdated } = useModerationStats({
enabled: !!user && !authLoading && !roleLoading && isModerator(),
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
// Check for suspicious versions (bypassed submission flow)
const checkSuspiciousVersions = useCallback(async () => {
if (!user || !isModerator()) return;
// Query all version tables for suspicious entries (no changed_by)
const queries = [
supabase.from('park_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('ride_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('company_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
supabase.from('ride_model_versions').select('*', { count: 'exact', head: true }).is('created_by', null),
];
const results = await Promise.all(queries);
const totalCount = results.reduce((sum, result) => sum + (result.count || 0), 0);
setSuspiciousVersionsCount(totalCount);
}, [user, isModerator]);
useEffect(() => {
checkSuspiciousVersions();
}, [checkSuspiciousVersions]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await refreshStats();
await checkSuspiciousVersions();
// Refresh active tab's content
switch (activeTab) {
case 'moderation':
moderationQueueRef.current?.refresh();
break;
case 'reports':
reportsQueueRef.current?.refresh();
break;
case 'activity':
recentActivityRef.current?.refresh();
break;
}
setTimeout(() => setIsRefreshing(false), 500);
}, [refreshStats, checkSuspiciousVersions, activeTab]);
const handleStatCardClick = (cardType: 'submissions' | 'reports' | 'flagged') => {
switch (cardType) {
case 'submissions':
setActiveTab('moderation');
break;
case 'reports':
setActiveTab('reports');
break;
case 'flagged':
setActiveTab('moderation');
break;
}
};
useEffect(() => {
if (!authLoading && !roleLoading) {
if (!user) {
navigate('/auth');
return;
}
if (!isModerator()) {
navigate('/');
return;
}
}
}, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading || mfaLoading) {
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground mt-1">Central hub for all moderation activity</p>
</div>
{/* Skeleton for stat cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-11 w-11 rounded-lg" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-10 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Skeleton for tabs */}
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<QueueSkeleton count={3} />
</div>
</div>
</AdminLayout>
);
}
if (!user || !isModerator()) {
return null;
}
const statCards = [
{
label: 'Pending Submissions',
value: stats.pendingSubmissions,
icon: FileText,
color: 'amber',
type: 'submissions' as const,
},
{
label: 'Open Reports',
value: stats.openReports,
icon: Flag,
color: 'red',
type: 'reports' as const,
},
{
label: 'Flagged Content',
value: stats.flaggedContent,
icon: AlertCircle,
color: 'orange',
type: 'flagged' as const,
},
];
return (
<AdminLayout
onRefresh={handleRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated ?? undefined}
isRefreshing={isRefreshing}
>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground mt-1">
Central hub for all moderation activity
</p>
</div>
{/* Security Warning for Suspicious Versions */}
{suspiciousVersionsCount > 0 && (
<Alert variant="destructive" className="border-red-500/50 bg-red-500/10">
<ShieldAlert className="h-5 w-5" />
<AlertDescription className="ml-2">
<strong>Security Alert:</strong> {suspiciousVersionsCount} entity version{suspiciousVersionsCount !== 1 ? 's' : ''} detected without user attribution.
This may indicate submission flow bypass. Check admin audit logs for details.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{statCards.map((card) => {
const Icon = card.icon;
const colorClasses = {
amber: {
card: 'hover:border-amber-500/50',
bg: 'bg-amber-500/10',
icon: 'text-amber-600 dark:text-amber-400',
},
red: {
card: 'hover:border-red-500/50',
bg: 'bg-red-500/10',
icon: 'text-red-600 dark:text-red-400',
},
orange: {
card: 'hover:border-orange-500/50',
bg: 'bg-orange-500/10',
icon: 'text-orange-600 dark:text-orange-400',
},
};
const colors = colorClasses[card.color as keyof typeof colorClasses];
return (
<Card
key={card.label}
className={`${colors.card} transition-colors cursor-pointer`}
onClick={() => handleStatCardClick(card.type)}
>
<CardContent className="flex items-center justify-between p-6">
<div className="flex items-center gap-4">
<div className={`p-3 ${colors.bg} rounded-lg`}>
<Icon className={`w-5 h-5 ${colors.icon}`} />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
{card.label}
</p>
</div>
</div>
<div className="text-4xl font-bold">{card.value}</div>
</CardContent>
</Card>
);
})}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 h-auto p-1">
<TabsTrigger value="moderation" className="flex items-center gap-2 py-3">
<FileText className="w-4 h-4" />
<span className="hidden sm:inline">Moderation Queue</span>
<span className="sm:hidden">Queue</span>
{stats.pendingSubmissions > 0 && (
<Badge variant="secondary" className="ml-1 bg-amber-500/20 text-amber-700 dark:text-amber-300">
{stats.pendingSubmissions}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="reports" className="flex items-center gap-2 py-3">
<Flag className="w-4 h-4" />
<span className="hidden sm:inline">Reports</span>
<span className="sm:hidden">Reports</span>
{stats.openReports > 0 && (
<Badge variant="secondary" className="ml-1 bg-red-500/20 text-red-700 dark:text-red-300">
{stats.openReports}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="activity" className="flex items-center gap-2 py-3">
<Activity className="w-4 h-4" />
<span className="hidden sm:inline">Recent Activity</span>
<span className="sm:hidden">Activity</span>
</TabsTrigger>
</TabsList>
<TabsContent value="moderation" className="mt-6" forceMount={true} hidden={activeTab !== 'moderation'}>
<ModerationQueue ref={moderationQueueRef} optimisticallyUpdateStats={optimisticallyUpdateStats} />
</TabsContent>
<TabsContent value="reports" className="mt-6" forceMount={true} hidden={activeTab !== 'reports'}>
<ReportsQueue ref={reportsQueueRef} />
</TabsContent>
<TabsContent value="activity" className="mt-6" forceMount={true} hidden={activeTab !== 'activity'}>
<RecentActivity ref={recentActivityRef} />
</TabsContent>
</Tabs>
</div>
</MFAGuard>
</AdminLayout>
);
}

View File

@@ -0,0 +1,81 @@
import { useRef, useCallback } from 'react';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationStats } from '@/hooks/useModerationStats';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminModeration() {
useDocumentTitle('Moderation Queue - Admin');
const { isLoading, isAuthorized, needsMFA, user } = useAdminGuard();
const moderationQueueRef = useRef<ModerationQueueRef>(null);
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
const { lastUpdated } = useModerationStats({
enabled: isAuthorized,
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
const handleRefresh = useCallback(() => {
moderationQueueRef.current?.refresh();
}, []);
if (isLoading) {
return (
<AdminLayout
onRefresh={handleRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated ?? undefined}
>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Moderation Queue</h1>
<p className="text-muted-foreground mt-1">
Review and manage pending content submissions
</p>
</div>
<QueueSkeleton count={5} />
</div>
</AdminLayout>
);
}
if (!isAuthorized) {
return null;
}
return (
<AdminLayout
onRefresh={handleRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated ?? undefined}
>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Moderation Queue</h1>
<p className="text-muted-foreground mt-1">
Review and manage pending content submissions
</p>
</div>
<ModerationQueue ref={moderationQueueRef} />
</div>
</MFAGuard>
</AdminLayout>
);
}

View File

@@ -0,0 +1,82 @@
import { useRef, useCallback } from 'react';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { ReportsQueue, ReportsQueueRef } from '@/components/moderation/ReportsQueue';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationStats } from '@/hooks/useModerationStats';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminReports() {
useDocumentTitle('Reports Queue - Admin');
const { isLoading, isAuthorized, needsMFA } = useAdminGuard();
const reportsQueueRef = useRef<ReportsQueueRef>(null);
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
const { lastUpdated, refresh: refreshStats } = useModerationStats({
enabled: isAuthorized,
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
const handleRefresh = useCallback(() => {
reportsQueueRef.current?.refresh();
refreshStats();
}, [refreshStats]);
if (isLoading) {
return (
<AdminLayout
onRefresh={handleRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated ?? undefined}
>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Reports</h1>
<p className="text-muted-foreground mt-1">
Review and resolve user-submitted reports
</p>
</div>
<QueueSkeleton count={5} />
</div>
</AdminLayout>
);
}
if (!isAuthorized) {
return null;
}
return (
<AdminLayout
onRefresh={handleRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated ?? undefined}
>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Reports</h1>
<p className="text-muted-foreground mt-1">
Review and resolve user-submitted reports
</p>
</div>
<ReportsQueue ref={reportsQueueRef} />
</div>
</MFAGuard>
</AdminLayout>
);
}

View File

@@ -0,0 +1,984 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner';
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle } from 'lucide-react';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminSettings() {
useDocumentTitle('Settings - Admin');
const { user } = useAuth();
const { isSuperuser, loading: roleLoading } = useUserRole();
const {
settings,
isLoading,
error,
updateSetting,
isUpdating,
getSettingsByCategory
} = useAdminSettings();
if (roleLoading || isLoading) {
return (
<AdminLayout>
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
</AdminLayout>
);
}
if (!user || !isSuperuser()) {
return (
<AdminLayout>
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground">You don't have permission to access admin settings.</p>
{error && (
<div className="text-sm text-red-500 p-4 bg-red-50 rounded-md">
Error details: {error.message}
</div>
)}
</div>
</AdminLayout>
);
}
if (!settings || settings.length === 0) {
return (
<AdminLayout>
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold mb-4">No Settings Found</h1>
<p className="text-muted-foreground">
No admin settings have been configured yet. Please contact your system administrator.
</p>
{error && (
<div className="text-sm text-red-500 p-4 bg-red-50 rounded-md">
Error details: {error.message}
</div>
)}
</div>
</AdminLayout>
);
}
const SettingInput = ({ setting }: { setting: any }) => {
const [localValue, setLocalValue] = useState(setting.setting_value);
const handleSubmit = async () => {
await updateSetting(setting.setting_key, localValue);
};
// Auto-flagging threshold settings
if (setting.setting_key === 'moderation.auto_flag_threshold') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-orange-500" />
<Label className="text-base font-medium">Auto-Flag Content Threshold</Label>
</div>
<p className="text-sm text-muted-foreground">
Content will be automatically flagged for review after receiving this many reports
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 report</SelectItem>
<SelectItem value="2">2 reports</SelectItem>
<SelectItem value="3">3 reports</SelectItem>
<SelectItem value="5">5 reports</SelectItem>
<SelectItem value="10">10 reports</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue} reports</Badge>
</div>
</div>
</Card>
);
}
// Review retention settings
if (setting.setting_key === 'moderation.review_retention_days') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-500" />
<Label className="text-base font-medium">Review Data Retention</Label>
</div>
<p className="text-sm text-muted-foreground">
How long to keep moderated content data before automatic cleanup
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="60">60 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="180">6 months</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="730">2 years</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue} days</Badge>
</div>
</div>
</Card>
);
}
// Ban duration settings
if (setting.setting_key === 'moderation.ban_durations') {
const [banDurations, setBanDurations] = useState<{[key: string]: number}>(
typeof localValue === 'object' ? localValue : { temporary: 7, extended: 30, permanent: 0 }
);
const updateBanDuration = (type: string, days: number) => {
const updated = { ...banDurations, [type]: days };
setBanDurations(updated);
setLocalValue(updated);
};
const [savingBanDurations, setSavingBanDurations] = useState(false);
const saveBanDurations = async () => {
setSavingBanDurations(true);
try {
await updateSetting(setting.setting_key, banDurations);
} finally {
setSavingBanDurations(false);
}
};
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-red-500" />
<Label className="text-base font-medium">User Ban Duration Options</Label>
</div>
<p className="text-sm text-muted-foreground">
Configure the available ban duration options for moderators
</p>
<div className="grid gap-4">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<Label className="font-medium">Temporary Ban</Label>
<p className="text-sm text-muted-foreground">Short-term restriction for minor violations</p>
</div>
<div className="flex items-center gap-2">
<Select
value={banDurations.temporary?.toString()}
onValueChange={(value) => updateBanDuration('temporary', parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 day</SelectItem>
<SelectItem value="3">3 days</SelectItem>
<SelectItem value="7">7 days</SelectItem>
<SelectItem value="14">14 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<Label className="font-medium">Extended Ban</Label>
<p className="text-sm text-muted-foreground">Longer restriction for serious violations</p>
</div>
<div className="flex items-center gap-2">
<Select
value={banDurations.extended?.toString()}
onValueChange={(value) => updateBanDuration('extended', parseInt(value))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="60">60 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="180">6 months</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<Label className="font-medium">Permanent Ban</Label>
<p className="text-sm text-muted-foreground">Indefinite restriction for severe violations</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="destructive">Permanent</Badge>
</div>
</div>
</div>
<Button
onClick={saveBanDurations}
loading={savingBanDurations}
loadingText="Saving..."
className="w-full"
trackingLabel="save-ban-duration-settings"
>
<Save className="w-4 h-4 mr-2" />
Save Ban Duration Settings
</Button>
</div>
</Card>
);
}
// Admin panel refresh mode setting
if (setting.setting_key === 'system.admin_panel_refresh_mode') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-blue-500" />
<Label className="text-base font-medium">Admin Panel Refresh Mode</Label>
</div>
<p className="text-sm text-muted-foreground">
Choose how the admin panel statistics refresh
</p>
<div className="flex items-center gap-4">
<Select
value={typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue}
onValueChange={(value) => {
setLocalValue(value);
updateSetting(setting.setting_key, JSON.stringify(value));
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual Only</SelectItem>
<SelectItem value="auto">Auto-refresh</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">
Current: {(typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue) === 'auto' ? 'Auto-refresh' : 'Manual'}
</Badge>
</div>
</div>
</Card>
);
}
// Admin panel poll interval setting
if (setting.setting_key === 'system.admin_panel_poll_interval') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-500" />
<Label className="text-base font-medium">Auto-refresh Interval</Label>
</div>
<p className="text-sm text-muted-foreground">
How often to automatically refresh admin panel statistics (when auto-refresh is enabled)
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 seconds</SelectItem>
<SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<SelectItem value="120">2 minutes</SelectItem>
<SelectItem value="300">5 minutes</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}s</Badge>
</div>
</div>
</Card>
);
}
// Auto-refresh strategy setting
if (setting.setting_key === 'auto_refresh_strategy') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-purple-500" />
<Label className="text-base font-medium">Auto-Refresh Strategy</Label>
</div>
<p className="text-sm text-muted-foreground">
How the moderation queue handles new items when they arrive
</p>
<div className="flex items-center gap-4">
<Select
value={typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue}
onValueChange={(value) => {
setLocalValue(value);
updateSetting(setting.setting_key, JSON.stringify(value));
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="merge">Merge silently</SelectItem>
<SelectItem value="replace">Replace all</SelectItem>
<SelectItem value="notify">Show notification</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">
Current: {typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue}
</Badge>
</div>
</div>
</Card>
);
}
// Retry settings
if (setting.setting_key === 'retry.max_attempts') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-blue-500" />
<Label className="text-base font-medium">Maximum Retry Attempts</Label>
</div>
<p className="text-sm text-muted-foreground">
How many times to retry failed operations (entity/photo submissions)
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
<SelectItem value="10">10 attempts</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'retry.base_delay') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-500" />
<Label className="text-base font-medium">Initial Retry Delay</Label>
</div>
<p className="text-sm text-muted-foreground">
Milliseconds to wait before first retry attempt
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">100ms (very fast)</SelectItem>
<SelectItem value="500">500ms</SelectItem>
<SelectItem value="1000">1 second</SelectItem>
<SelectItem value="2000">2 seconds</SelectItem>
<SelectItem value="5000">5 seconds</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}ms</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'retry.max_delay') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-500" />
<Label className="text-base font-medium">Maximum Retry Delay</Label>
</div>
<p className="text-sm text-muted-foreground">
Maximum delay between retry attempts (exponential backoff cap)
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5000">5 seconds</SelectItem>
<SelectItem value="10000">10 seconds</SelectItem>
<SelectItem value="20000">20 seconds</SelectItem>
<SelectItem value="30000">30 seconds</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}ms</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'retry.backoff_multiplier') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-purple-500" />
<Label className="text-base font-medium">Backoff Multiplier</Label>
</div>
<p className="text-sm text-muted-foreground">
Growth rate for exponential backoff between retries
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseFloat(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2">2x</SelectItem>
<SelectItem value="2.5">2.5x</SelectItem>
<SelectItem value="3">3x</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}x</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'circuit_breaker.failure_threshold') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
<Label className="text-base font-medium">Circuit Breaker Threshold</Label>
</div>
<p className="text-sm text-muted-foreground">
Number of consecutive failures before blocking all requests temporarily
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 failures</SelectItem>
<SelectItem value="5">5 failures</SelectItem>
<SelectItem value="10">10 failures</SelectItem>
<SelectItem value="15">15 failures</SelectItem>
<SelectItem value="20">20 failures</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'circuit_breaker.reset_timeout') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-red-500" />
<Label className="text-base font-medium">Circuit Reset Timeout</Label>
</div>
<p className="text-sm text-muted-foreground">
How long to wait before testing if service has recovered
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000">30 seconds</SelectItem>
<SelectItem value="60000">1 minute</SelectItem>
<SelectItem value="120000">2 minutes</SelectItem>
<SelectItem value="300000">5 minutes</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {Math.floor(Number(localValue) / 1000)}s</Badge>
</div>
</div>
</Card>
);
}
if (setting.setting_key === 'circuit_breaker.monitoring_window') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-yellow-500" />
<Label className="text-base font-medium">Monitoring Window</Label>
</div>
<p className="text-sm text-muted-foreground">
Time window to track failures for circuit breaker
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="60000">1 minute</SelectItem>
<SelectItem value="120000">2 minutes</SelectItem>
<SelectItem value="180000">3 minutes</SelectItem>
<SelectItem value="300000">5 minutes</SelectItem>
<SelectItem value="600000">10 minutes</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {Math.floor(Number(localValue) / 60000)}min</Badge>
</div>
</div>
</Card>
);
}
// Helper to check if value is boolean
const isBooleanSetting = (value: any) => {
return value === true || value === false ||
value === 'true' || value === 'false';
};
// Boolean/switch settings - check by value type instead of key pattern
if (isBooleanSetting(setting.setting_value)) {
const getSettingIcon = () => {
if (setting.setting_key.includes('email_alerts')) return <Bell className="w-4 h-4 text-blue-500" />;
if (setting.setting_key.includes('require_approval')) return <Shield className="w-4 h-4 text-orange-500" />;
if (setting.setting_key.includes('auto_cleanup')) return <Trash2 className="w-4 h-4 text-green-500" />;
return <Settings className="w-4 h-4 text-gray-500" />;
};
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
{getSettingIcon()}
<Label className="text-base font-medium">{setting.description}</Label>
</div>
<p className="text-sm text-muted-foreground">
{setting.setting_key.includes('email_alerts') && "Receive email notifications for this event"}
{setting.setting_key.includes('require_approval') && "Items must be manually approved before being published"}
{setting.setting_key.includes('auto_cleanup') && "Automatically remove old or rejected content"}
{(!setting.setting_key.includes('email_alerts') && !setting.setting_key.includes('require_approval') && !setting.setting_key.includes('auto_cleanup')) && "Toggle this feature on or off"}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={localValue ? "default" : "secondary"}>
{localValue ? "Enabled" : "Disabled"}
</Badge>
<Switch
checked={localValue === true || localValue === 'true'}
onCheckedChange={(checked) => {
setLocalValue(checked);
updateSetting(setting.setting_key, checked);
}}
/>
</div>
</div>
</Card>
);
}
// Numeric threshold settings
if (setting.setting_key.includes('threshold') || setting.setting_key.includes('limit') || setting.setting_key.includes('max_')) {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-purple-500" />
<Label className="text-base font-medium">{setting.description}</Label>
</div>
<p className="text-sm text-muted-foreground">
Set the numeric limit for this system parameter
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Input
type="number"
value={localValue}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
setLocalValue(value);
}}
className="w-24"
min="0"
/>
<Button
onClick={handleSubmit}
loading={isUpdating}
loadingText=""
size="sm"
trackingLabel="save-threshold-setting"
>
<Save className="w-4 h-4" />
</Button>
</div>
<Badge variant="outline">Current: {localValue}</Badge>
</div>
</div>
</Card>
);
}
// Default string input
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-gray-500" />
<Label className="text-base font-medium">{setting.description}</Label>
</div>
<div className="flex items-center gap-2">
<Input
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
className="flex-1"
/>
<Button
onClick={handleSubmit}
loading={isUpdating}
loadingText=""
size="sm"
trackingLabel="save-setting"
>
<Save className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
};
return (
<AdminLayout>
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Admin Settings</h1>
<p className="text-muted-foreground mt-1">
Configure system-wide settings and preferences
</p>
</div>
<Tabs defaultValue="moderation" className="space-y-6">
<TabsList className="grid w-full grid-cols-7">
<TabsTrigger value="moderation" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Moderation</span>
</TabsTrigger>
<TabsTrigger value="user_management" className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span className="hidden sm:inline">Users</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="flex items-center gap-2">
<Bell className="w-4 h-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="resilience" className="flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
<span className="hidden sm:inline">Resilience</span>
</TabsTrigger>
<TabsTrigger value="system" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">System</span>
</TabsTrigger>
<TabsTrigger value="integrations" className="flex items-center gap-2">
<Plug className="w-4 h-4" />
<span className="hidden sm:inline">Integrations</span>
</TabsTrigger>
<TabsTrigger value="testing" className="flex items-center gap-2">
<Loader2 className="w-4 h-4" />
<span className="hidden sm:inline">Testing</span>
</TabsTrigger>
<TabsTrigger value="integration-tests" className="flex items-center gap-2">
<TestTube className="w-4 h-4" />
<span className="hidden sm:inline">Integration Tests</span>
</TabsTrigger>
</TabsList>
<TabsContent value="moderation">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Moderation Settings
</CardTitle>
<CardDescription>
Configure content moderation rules, thresholds, and automated actions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{getSettingsByCategory('moderation').length > 0 ? (
getSettingsByCategory('moderation').map((setting) => (
<SettingInput key={setting.id} setting={setting} />
))
) : (
<div className="text-center py-8 text-muted-foreground">
<Shield className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No moderation settings configured yet.</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="user_management">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
User Management
</CardTitle>
<CardDescription>
Configure user registration, profile settings, and account policies
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{getSettingsByCategory('user_management').length > 0 ? (
getSettingsByCategory('user_management').map((setting) => (
<SettingInput key={setting.id} setting={setting} />
))
) : (
<div className="text-center py-8 text-muted-foreground">
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No user management settings configured yet.</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Notification Settings
</CardTitle>
<CardDescription>
Configure email alerts, push notifications, and communication preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{getSettingsByCategory('notifications').length > 0 ? (
getSettingsByCategory('notifications').map((setting) => (
<SettingInput key={setting.id} setting={setting} />
))
) : (
<div className="text-center py-8 text-muted-foreground">
<Bell className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No notification settings configured yet.</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="resilience">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Resilience & Retry Configuration
</CardTitle>
<CardDescription>
Configure automatic retry behavior and circuit breaker settings for handling transient failures
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-950 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-900 dark:text-blue-100">
<p className="font-medium mb-2">About Retry & Circuit Breaker</p>
<ul className="space-y-1 text-blue-800 dark:text-blue-200">
<li>• <strong>Retry Logic:</strong> Automatically retries failed operations (network issues, timeouts)</li>
<li>• <strong>Circuit Breaker:</strong> Prevents system overload by blocking requests during outages</li>
<li>• <strong>When to adjust:</strong> Increase retries for unstable networks, decrease for fast-fail scenarios</li>
</ul>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Retry Settings</h3>
{getSettingsByCategory('system')
.filter(s => s.setting_key.startsWith('retry.'))
.map(setting => <SettingInput key={setting.id} setting={setting} />)}
</div>
<div className="border-t pt-6 space-y-4">
<h3 className="text-lg font-semibold">Circuit Breaker Settings</h3>
{getSettingsByCategory('system')
.filter(s => s.setting_key.startsWith('circuit_breaker.'))
.map(setting => <SettingInput key={setting.id} setting={setting} />)}
</div>
<div className="bg-yellow-50 dark:bg-yellow-950 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-yellow-900 dark:text-yellow-100">
<p className="font-medium mb-1">Configuration Changes</p>
<p className="text-yellow-800 dark:text-yellow-200">
Settings take effect immediately but may be cached for up to 5 minutes in active sessions. Consider monitoring error logs after changes to verify behavior.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
System Configuration
</CardTitle>
<CardDescription>
Configure system-wide settings, maintenance options, and technical parameters
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).length > 0 ? (
getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).map((setting) => (
<SettingInput key={setting.id} setting={setting} />
))
) : (
<div className="text-center py-8 text-muted-foreground">
<Settings className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No system settings configured yet.</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="integrations">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="w-5 h-5" />
Integration Settings
</CardTitle>
<CardDescription>
Configure third-party integrations and external services
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{getSettingsByCategory('integrations').length > 0 ? (
getSettingsByCategory('integrations').map((setting) => (
<SettingInput key={setting.id} setting={setting} />
))
) : (
<div className="text-center py-8 text-muted-foreground">
<Plug className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No integration settings configured yet.</p>
</div>
)}
<NovuMigrationUtility />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="testing">
<TestDataGenerator />
</TabsContent>
<TabsContent value="integration-tests">
<IntegrationTestRunner />
</TabsContent>
</Tabs>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,105 @@
import { Component, ReactNode } from 'react';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { logger } from '@/lib/logger';
import { SystemActivityLog } from '@/components/admin/SystemActivityLog';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
logger.error('System Activity Log Error', { error, errorInfo });
}
render() {
if (this.state.hasError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading system activity log</AlertTitle>
<AlertDescription>
{this.state.error?.message || 'An unexpected error occurred'}
</AlertDescription>
</Alert>
);
}
return this.props.children;
}
}
export default function AdminSystemLog() {
useDocumentTitle('System Activity Log - Admin');
const { isLoading, isAuthorized } = useAdminGuard(false); // No MFA required for viewing logs
if (isLoading) {
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">System Activity Log</h1>
<p className="text-muted-foreground mt-1">
Complete audit trail of all system changes and administrative actions
</p>
</div>
<Card>
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-4 p-3 border-l-2 border-l-muted">
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-3 w-24" />
</div>
))}
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
if (!isAuthorized) {
return null;
}
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">System Activity Log</h1>
<p className="text-muted-foreground mt-1">
Complete audit trail of all system changes and administrative actions
</p>
</div>
<ErrorBoundary>
<SystemActivityLog limit={100} showFilters={true} />
</ErrorBoundary>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,63 @@
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { UserManagement } from '@/components/admin/UserManagement';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminUsers() {
useDocumentTitle('User Management - Admin');
const { isLoading, isAuthorized, needsMFA } = useAdminGuard();
if (isLoading) {
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Management</h1>
<p className="text-muted-foreground mt-1">
Manage user profiles, roles, and permissions
</p>
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-4 p-4 border rounded-lg">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-24" />
</div>
))}
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
if (!isAuthorized) {
return null;
}
return (
<AdminLayout>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Management</h1>
<p className="text-muted-foreground mt-1">
Manage user profiles, roles, and permissions
</p>
</div>
<UserManagement />
</div>
</MFAGuard>
</AdminLayout>
);
}

661
src-old/pages/Auth.tsx Normal file
View File

@@ -0,0 +1,661 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService';
import { StorageWarning } from '@/components/auth/StorageWarning';
import { MFAChallenge } from '@/components/auth/MFAChallenge';
import { verifyMfaUpgrade } from '@/lib/authService';
import { setAuthMethod } from '@/lib/sessionFlags';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function Auth() {
useDocumentTitle('Sign In');
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const {
toast
} = useToast();
const [loading, setLoading] = useState(false);
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const emailParam = searchParams.get('email');
const messageParam = searchParams.get('message');
const showPasswordSetupMessage = messageParam === 'complete-password-setup';
const [formData, setFormData] = useState({
email: emailParam || '',
password: '',
confirmPassword: '',
username: '',
displayName: ''
});
const defaultTab = searchParams.get('tab') || 'signin';
const { user } = useAuth();
// Pre-fill email from query param
useEffect(() => {
if (emailParam) {
setFormData(prev => ({ ...prev, email: emailParam }));
}
}, [emailParam]);
// Auto-redirect when user is authenticated
useEffect(() => {
if (user) {
const redirectTo = searchParams.get('redirect') || '/';
navigate(redirectTo);
}
}, [user, navigate, searchParams]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Validate CAPTCHA
if (!signInCaptchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
// Consume token immediately to prevent reuse
const tokenToUse = signInCaptchaToken;
setSignInCaptchaToken(null);
try {
const {
data,
error
} = await supabase.auth.signInWithPassword({
email: formData.email,
password: formData.password,
options: {
captchaToken: tokenToUse
}
});
if (error) throw error;
// CRITICAL: Check ban status immediately after successful authentication
const { data: profile } = await supabase
.from('profiles')
.select('banned, ban_reason')
.eq('user_id', data.user.id)
.single();
if (profile?.banned) {
// Sign out immediately
await supabase.auth.signOut();
const reason = profile.ban_reason
? `Reason: ${profile.ban_reason}`
: 'Contact support for assistance.';
toast({
variant: "destructive",
title: "Account Suspended",
description: `Your account has been suspended. ${reason}`,
duration: 10000
});
setLoading(false);
return; // Stop authentication flow
}
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
// Track auth method for audit logging
setAuthMethod('password');
// Check if MFA step-up is required
const { handlePostAuthFlow } = await import('@/lib/authService');
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
if (postAuthResult.success && postAuthResult.data?.shouldRedirect) {
// Get the TOTP factor ID
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return; // Stay on page, show MFA modal
}
}
// Verify session was stored
setTimeout(async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast({
variant: "destructive",
title: "Session Error",
description: "Login succeeded but session was not stored. Please check your browser settings and enable cookies/storage."
});
} else {
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
}
}, 500);
} catch (error) {
// Reset CAPTCHA widget to force fresh token generation
setSignInCaptchaKey(prev => prev + 1);
// Enhanced error messages
const errorMsg = getErrorMessage(error);
let errorMessage = errorMsg;
if (errorMsg.includes('Invalid login credentials')) {
errorMessage = 'Invalid email or password. Please try again.';
} else if (errorMsg.includes('Email not confirmed')) {
errorMessage = 'Please confirm your email address before signing in.';
} else if (error instanceof Error && error.message.includes('Too many requests')) {
errorMessage = 'Too many login attempts. Please wait a few minutes and try again.';
}
toast({
variant: "destructive",
title: "Sign in failed",
description: errorMessage
});
} finally {
setLoading(false);
}
};
const handleMfaSuccess = async () => {
// Verify AAL upgrade was successful
const { data: { session } } = await supabase.auth.getSession();
const verification = await verifyMfaUpgrade(session);
if (!verification.success) {
toast({
variant: "destructive",
title: "MFA Verification Failed",
description: verification.error || "Failed to upgrade session. Please try again."
});
// Force sign out on verification failure
await supabase.auth.signOut();
setMfaFactorId(null);
return;
}
setMfaFactorId(null);
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
};
const handleMfaCancel = () => {
setMfaFactorId(null);
setSignInCaptchaKey(prev => prev + 1);
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
toast({
variant: "destructive",
title: "Passwords don't match",
description: "Please make sure your passwords match."
});
setLoading(false);
return;
}
// Validate password length
if (formData.password.length < 6) {
toast({
variant: "destructive",
title: "Password too short",
description: "Password must be at least 6 characters long."
});
setLoading(false);
return;
}
// Validate CAPTCHA
if (!captchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
// Consume token immediately to prevent reuse
const tokenToUse = captchaToken;
setCaptchaToken(null);
try {
const {
data,
error
} = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
captchaToken: tokenToUse,
data: {
username: formData.username,
display_name: formData.displayName
}
}
});
if (error) throw error;
// Register user with Novu (non-blocking)
if (data.user) {
const userId = data.user.id;
notificationService.createSubscriber({
subscriberId: userId,
email: formData.email,
firstName: formData.username, // Send username as firstName to Novu
data: {
username: formData.username,
}
}).catch(err => {
handleNonCriticalError(err, {
action: 'Register Novu subscriber',
userId,
metadata: {
email: formData.email,
context: 'post_signup'
}
});
});
}
toast({
title: "Welcome to ThrillWiki!",
description: "Please check your email to verify your account."
});
} catch (error) {
// Reset CAPTCHA widget to force fresh token generation
setCaptchaKey(prev => prev + 1);
toast({
variant: "destructive",
title: "Sign up failed",
description: getErrorMessage(error)
});
} finally {
setLoading(false);
}
};
const handleMagicLinkSignIn = async (email: string) => {
if (!email) {
toast({
variant: "destructive",
title: "Email required",
description: "Please enter your email address to receive a magic link."
});
return;
}
setMagicLinkLoading(true);
try {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) throw error;
toast({
title: "Magic link sent!",
description: "Check your email for a sign-in link."
});
} catch (error) {
toast({
variant: "destructive",
title: "Failed to send magic link",
description: getErrorMessage(error)
});
} finally {
setMagicLinkLoading(false);
}
};
const handleSocialSignIn = async (provider: 'google' | 'discord') => {
try {
const {
error
} = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
// Request additional scopes for avatar access
scopes: provider === 'google'
? 'email profile'
: 'identify email'
}
});
if (error) throw error;
} catch (error) {
toast({
variant: "destructive",
title: "Social sign in failed",
description: getErrorMessage(error)
});
}
};
return <div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto">
<StorageWarning />
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-4">
<h1 className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
ThrillWiki
</h1>
</div>
<p className="text-muted-foreground">
Join the ultimate theme park community
</p>
</div>
<Card>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin">
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your ThrillWiki account
</CardDescription>
</CardHeader>
<CardContent>
{showPasswordSetupMessage && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Password setup in progress.</strong> Check your email for a confirmation link. After confirming your email, sign in below with your email and password.
</AlertDescription>
</Alert>
)}
{mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signin-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="signin-email" name="email" type="email" placeholder="your@email.com" value={formData.email} onChange={handleInputChange} className="pl-10" autoComplete="email" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signin-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="signin-password" name="password" type={showPassword ? "text" : "password"} placeholder="Your password" value={formData.password} onChange={handleInputChange} className="pl-10 pr-10" autoComplete="current-password" required />
<Button type="button" variant="ghost" size="sm" className="absolute right-0 top-0 h-full px-3" onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Security Verification</Label>
<TurnstileCaptcha
key={signInCaptchaKey}
onSuccess={setSignInCaptchaToken}
onError={() => setSignInCaptchaToken(null)}
onExpire={() => setSignInCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</div>
<Button
type="submit"
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground"
disabled={loading || !signInCaptchaToken}
>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div className="mt-4">
<Button
variant="outline"
onClick={() => handleMagicLinkSignIn(formData.email)}
disabled={!formData.email || magicLinkLoading}
className="w-full"
>
<Zap className="w-4 h-4 mr-2" />
{magicLinkLoading ? "Sending..." : "Send Magic Link"}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Enter your email above and click to receive a sign-in link
</p>
</div>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<Button variant="outline" onClick={() => handleSocialSignIn('google')} className="w-full">
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Google
</Button>
<Button variant="outline" onClick={() => handleSocialSignIn('discord')} className="w-full">
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.19.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</Button>
</div>
</div>
</>
)}
</CardContent>
</TabsContent>
<TabsContent value="signup">
<CardHeader>
<CardTitle>Create account</CardTitle>
<CardDescription>
Join the ThrillWiki community today
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignUp} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="username" name="username" placeholder="username" value={formData.username} onChange={handleInputChange} className="pl-10" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input id="displayName" name="displayName" placeholder="Display Name" value={formData.displayName} onChange={handleInputChange} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="signup-email" name="email" type="email" placeholder="your@email.com" value={formData.email} onChange={handleInputChange} className="pl-10" autoComplete="email" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="signup-password" name="password" type={showPassword ? "text" : "password"} placeholder="Create a password" value={formData.password} onChange={handleInputChange} className="pl-10 pr-10" autoComplete="new-password" required />
<Button type="button" variant="ghost" size="sm" className="absolute right-0 top-0 h-full px-3" onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input id="confirmPassword" name="confirmPassword" type="password" placeholder="Confirm your password" value={formData.confirmPassword} onChange={handleInputChange} className="pl-10" autoComplete="new-password" required />
</div>
</div>
<div className="space-y-2">
<Label>Security Verification</Label>
<TurnstileCaptcha
key={captchaKey}
onSuccess={setCaptchaToken}
onError={() => setCaptchaToken(null)}
onExpire={() => setCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
className="flex justify-center"
/>
</div>
<Button
type="submit"
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground"
disabled={loading || !captchaToken}
>
{loading ? "Creating account..." : "Create Account"}
</Button>
</form>
<div className="mt-4">
<Button
variant="outline"
onClick={() => handleMagicLinkSignIn(formData.email)}
disabled={!formData.email || magicLinkLoading}
className="w-full"
>
<Zap className="w-4 h-4 mr-2" />
{magicLinkLoading ? "Sending..." : "Sign up with Magic Link"}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Skip the password - just enter your email above
</p>
</div>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<Button variant="outline" onClick={() => handleSocialSignIn('google')} className="w-full">
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Google
</Button>
<Button variant="outline" onClick={() => handleSocialSignIn('discord')} className="w-full">
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.19.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</Button>
</div>
</div>
<Alert className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
By signing up, you agree to our Terms of Service and Privacy Policy.
</AlertDescription>
</Alert>
</CardContent>
</TabsContent>
</Tabs>
</Card>
</div>
</main>
</div>;
}

View File

@@ -0,0 +1,343 @@
import { useEffect, useState } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { Loader2, CheckCircle2 } from 'lucide-react';
import { Header } from '@/components/layout/Header';
import { handlePostAuthFlow, verifyMfaUpgrade } from '@/lib/authService';
import type { AuthMethod } from '@/types/auth';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { getErrorMessage } from '@/lib/errorHandler';
import { MFAStepUpModal } from '@/components/auth/MFAStepUpModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AuthCallback() {
useDocumentTitle('Sign In - Processing');
const navigate = useNavigate();
const { toast } = useToast();
const [status, setStatus] = useState<'processing' | 'success' | 'error' | 'mfa_required'>('processing');
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [isRecoveryMode, setIsRecoveryMode] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [settingPassword, setSettingPassword] = useState(false);
useEffect(() => {
const processOAuthCallback = async () => {
try {
// Check if this is a password recovery flow first
const hash = window.location.hash;
if (hash.includes('type=recovery')) {
setIsRecoveryMode(true);
setStatus('success'); // Stop the loading spinner
return; // Don't process further
}
// Get the current session
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
throw sessionError;
}
if (!session) {
navigate('/auth');
return;
}
const user = session.user;
// CRITICAL: Check ban status immediately after getting session
const { data: banProfile } = await supabase
.from('profiles')
.select('banned, ban_reason')
.eq('user_id', user.id)
.single();
if (banProfile?.banned) {
await supabase.auth.signOut();
const reason = banProfile.ban_reason
? `Reason: ${banProfile.ban_reason}`
: 'Contact support for assistance.';
toast({
variant: 'destructive',
title: 'Account Suspended',
description: `Your account has been suspended. ${reason}`,
duration: 10000
});
navigate('/auth');
return; // Stop OAuth processing
}
// Check if this is a new OAuth user (created within last minute)
const createdAt = new Date(user.created_at);
const now = new Date();
const isNewUser = (now.getTime() - createdAt.getTime()) < 60000; // 1 minute
// Check if user has an OAuth provider
const provider = user.app_metadata?.provider;
const isOAuthUser = provider === 'google' || provider === 'discord';
// If new OAuth user, process profile
if (isNewUser && isOAuthUser) {
setStatus('processing');
try {
const { data, error, requestId } = await invokeWithTracking(
'process-oauth-profile',
{},
user.id
);
if (error) {
// Don't throw - allow sign-in to continue even if profile processing fails
}
} catch (error) {
// Continue anyway - don't block sign-in
}
}
// Determine authentication method
let authMethod: AuthMethod = 'magiclink';
if (isOAuthUser) {
authMethod = 'oauth';
}
// Unified post-authentication flow for ALL methods (OAuth, magic link, etc.)
const result = await handlePostAuthFlow(session, authMethod);
if (result.success && result.data?.shouldRedirect) {
// Get factor ID and show modal instead of redirecting
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setStatus('mfa_required');
return;
}
}
setStatus('success');
// Show success message
toast({
title: 'Welcome to ThrillWiki!',
description: isNewUser
? 'Your account has been created successfully.'
: 'You have been signed in successfully.',
});
// Redirect to home after a short delay
setTimeout(() => {
navigate('/');
}, 500);
} catch (error) {
const errorMsg = getErrorMessage(error);
setStatus('error');
toast({
variant: 'destructive',
title: 'Sign in error',
description: errorMsg,
});
// Redirect to auth page after error
setTimeout(() => {
navigate('/auth');
}, 2000);
}
};
processOAuthCallback();
}, [navigate, toast]);
const handleMfaSuccess = async () => {
const { data: { session } } = await supabase.auth.getSession();
const verification = await verifyMfaUpgrade(session);
if (!verification.success) {
toast({
variant: "destructive",
title: "MFA Verification Failed",
description: verification.error || "Failed to upgrade session."
});
await supabase.auth.signOut();
navigate('/auth');
return;
}
setMfaFactorId(null);
setStatus('success');
toast({
title: 'Welcome to ThrillWiki!',
description: 'You have been signed in successfully.',
});
setTimeout(() => navigate('/'), 500);
};
const handlePasswordReset = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast({
variant: 'destructive',
title: 'Passwords do not match',
description: 'Please make sure both passwords are identical.',
});
return;
}
if (newPassword.length < 8) {
toast({
variant: 'destructive',
title: 'Password too short',
description: 'Password must be at least 8 characters long.',
});
return;
}
setSettingPassword(true);
try {
const { error } = await supabase.auth.updateUser({
password: newPassword
});
if (error) throw error;
toast({
title: 'Password Set Successfully!',
description: 'You can now sign in with your email and password.',
});
setTimeout(() => {
navigate('/auth');
}, 1500);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
variant: 'destructive',
title: 'Failed to set password',
description: errorMsg,
});
setSettingPassword(false);
}
};
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto text-center">
{isRecoveryMode ? (
<Card className="w-full">
<CardHeader>
<CardTitle>Set Your New Password</CardTitle>
<CardDescription>
Enter a new password for your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordReset} className="space-y-4">
<div className="text-left">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
required
minLength={8}
/>
</div>
<div className="text-left">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
required
/>
</div>
<Button
type="submit"
className="w-full"
loading={settingPassword}
loadingText="Setting Password..."
trackingLabel="set-password-recovery"
>
Set Password
</Button>
</form>
</CardContent>
</Card>
) : (
<div className="flex flex-col items-center gap-4">
{status === 'processing' && (
<>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<h2 className="text-2xl font-bold">Setting up your profile...</h2>
<p className="text-muted-foreground">
We're preparing your ThrillWiki experience
</p>
</>
)}
{status === 'success' && (
<>
<div className="h-8 w-8 rounded-full bg-green-500 flex items-center justify-center">
<span className="text-white text-xl">✓</span>
</div>
<h2 className="text-2xl font-bold">Welcome!</h2>
<p className="text-muted-foreground">
Redirecting you to ThrillWiki...
</p>
</>
)}
{status === 'error' && (
<>
<div className="h-8 w-8 rounded-full bg-destructive flex items-center justify-center">
<span className="text-destructive-foreground text-xl">✕</span>
</div>
<h2 className="text-2xl font-bold">Something went wrong</h2>
<p className="text-muted-foreground">
Redirecting you to sign in...
</p>
</>
)}
</div>
)}
</div>
</main>
{status === 'mfa_required' && mfaFactorId && (
<MFAStepUpModal
open={true}
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={() => {
setMfaFactorId(null);
navigate('/auth');
}}
/>
)}
</div>
);
}

144
src-old/pages/BlogIndex.tsx Normal file
View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { BlogPostCard } from '@/components/blog/BlogPostCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
const POSTS_PER_PAGE = 9;
export default function BlogIndex() {
useDocumentTitle('Blog');
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['blog-posts', page, searchQuery],
queryFn: async () => {
let query = supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url)', { count: 'exact' })
.eq('status', 'published')
.order('published_at', { ascending: false })
.range((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE - 1);
if (searchQuery) {
query = query.or(`title.ilike.%${searchQuery}%,content.ilike.%${searchQuery}%`);
}
const { data: posts, count, error } = await query;
if (error) throw error;
return { posts, totalCount: count || 0 };
},
});
const totalPages = Math.ceil((data?.totalCount || 0) / POSTS_PER_PAGE);
useOpenGraph({
title: 'Blog - ThrillWiki',
description: searchQuery
? `Search results for "${searchQuery}" in ThrillWiki Blog`
: 'News, updates, and stories from the world of theme parks and roller coasters',
imageUrl: data?.posts?.[0]?.featured_image_url ?? undefined,
imageId: data?.posts?.[0]?.featured_image_id ?? undefined,
type: 'website',
enabled: !isLoading
});
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-7xl">
<div className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4">ThrillWiki Blog</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Latest news, updates, and stories from the world of theme parks and roller coasters
</p>
</div>
<div className="max-w-md mx-auto mb-12">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search blog posts..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="pl-10"
/>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-[400px] rounded-lg" />
))}
</div>
) : data?.posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No blog posts found.</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.posts.map((post) => (
<BlogPostCard
key={post.id}
slug={post.slug}
title={post.title}
content={post.content}
featuredImageId={post.featured_image_id ?? undefined}
author={{
username: post.profiles.username,
displayName: post.profiles.display_name ?? undefined,
avatarUrl: post.profiles.avatar_url ?? undefined,
}}
publishedAt={post.published_at!}
viewCount={post.view_count ?? 0}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-12">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</>
)}
</div>
<Footer />
</div>
);
}

148
src-old/pages/BlogPost.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Calendar, Eye } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function BlogPost() {
const { slug } = useParams<{ slug: string }>();
const { data: post, isLoading } = useQuery({
queryKey: ['blog-post', slug],
queryFn: async () => {
const query = supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
.eq('slug', slug || '')
.eq('status', 'published')
.single();
const { data, error } = await query;
if (error) throw error;
return data;
},
enabled: !!slug,
});
// Update document title when post changes
useDocumentTitle(post?.title || 'Blog Post');
// Update Open Graph meta tags
useOpenGraph({
title: post?.title || '',
description: post?.content?.substring(0, 160),
imageUrl: post?.featured_image_url ?? undefined,
imageId: post?.featured_image_id ?? undefined,
type: 'article',
enabled: !!post
});
useEffect(() => {
if (slug) {
supabase.rpc('increment_blog_view_count', { post_slug: slug });
}
}, [slug]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl">
<Skeleton className="h-12 w-32 mb-8" />
<Skeleton className="h-16 w-full mb-4" />
<Skeleton className="h-8 w-2/3 mb-8" />
<Skeleton className="h-[400px] w-full mb-8" />
<Skeleton className="h-96 w-full" />
</div>
<Footer />
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl text-center">
<h1 className="text-2xl font-bold mb-4">Post Not Found</h1>
<Link to="/blog">
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
</div>
<Footer />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<article className="container mx-auto px-4 py-12 max-w-4xl">
<Link to="/blog" className="inline-block mb-8">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
<h1 className="text-5xl font-bold mb-6 leading-tight">
{post.title}
</h1>
<div className="flex items-center justify-between mb-8 pb-6 border-b">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarImage src={post.profiles.avatar_url ?? undefined} />
<AvatarFallback>
{post.profiles.display_name?.[0] || post.profiles.username[0]}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{post.profiles.display_name ?? post.profiles.username}
</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(post.published_at!), { addSuffix: true })}
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.view_count} views
</div>
</div>
</div>
</div>
</div>
{post.featured_image_id && (
<div className="mb-12 rounded-lg overflow-hidden shadow-2xl">
<img
src={getCloudflareImageUrl(post.featured_image_id, 'public')}
alt={post.title}
className="w-full"
/>
</div>
)}
<MarkdownRenderer content={post.content} />
</article>
<Footer />
</div>
);
}

120
src-old/pages/Contact.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { Mail, Clock, BookOpen, HelpCircle } from 'lucide-react';
import { ContactForm } from '@/components/contact/ContactForm';
import { ContactInfoCard } from '@/components/contact/ContactInfoCard';
import { ContactFAQ } from '@/components/contact/ContactFAQ';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Link } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function Contact() {
useDocumentTitle('Contact Us');
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Hero Section */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold mb-4">Get in Touch</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Have questions or feedback? We're here to help. Send us a message and we'll respond as soon as possible.
</p>
</div>
<div className="grid lg:grid-cols-3 gap-8 mb-12">
{/* Contact Form - Main Content */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Send Us a Message</CardTitle>
<CardDescription>
Fill out the form below and we'll get back to you within 24-48 hours
</CardDescription>
</CardHeader>
<CardContent>
<ContactForm />
</CardContent>
</Card>
</div>
{/* Sidebar - Quick Info */}
<div className="space-y-6">
{/* Email Support */}
<ContactInfoCard
icon={Mail}
title="Email Support"
description="Direct contact"
content={
<a
href="mailto:support@thrillwiki.com"
className="text-primary hover:underline font-medium"
>
support@thrillwiki.com
</a>
}
/>
{/* Response Time */}
<ContactInfoCard
icon={Clock}
title="Response Time"
description="Our commitment"
content={
<p className="text-sm">
We typically respond within <strong>24-48 hours</strong> during business days.
Complex inquiries may take longer.
</p>
}
/>
{/* Documentation */}
<ContactInfoCard
icon={BookOpen}
title="Documentation"
description="Self-service help"
content={
<div className="space-y-2 text-sm">
<Link
to="/submission-guidelines"
className="block text-primary hover:underline"
>
Submission Guidelines
</Link>
<Link
to="/terms"
className="block text-primary hover:underline"
>
Terms of Service
</Link>
<Link
to="/privacy"
className="block text-primary hover:underline"
>
Privacy Policy
</Link>
</div>
}
/>
{/* Help Resources */}
<ContactInfoCard
icon={HelpCircle}
title="Help Resources"
description="Before you contact us"
content={
<p className="text-sm">
Check out our FAQ section below for answers to common questions. Many issues can be resolved quickly by reviewing our documentation.
</p>
}
/>
</div>
</div>
{/* FAQ Section */}
<div className="mt-12">
<ContactFAQ />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,388 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { ArrowLeft, MapPin, Star, Globe, Calendar, Edit, Ruler } from 'lucide-react';
import { Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { DesignerPhotoGallery } from '@/components/companies/DesignerPhotoGallery';
import { handleNonCriticalError } from '@/lib/errorHandler';
// Lazy load admin form
const DesignerForm = lazy(() => import('@/components/admin/DesignerForm').then(m => ({ default: m.DesignerForm })));
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { submitCompanyUpdate } from '@/lib/companyHelpers';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { trackPageView } from '@/lib/viewTracking';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function DesignerDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [designer, setDesigner] = useState<Company | null>(null);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalRides, setTotalRides] = useState<number>(0);
const [totalPhotos, setTotalPhotos] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
// Update document title when designer changes
useDocumentTitle(designer?.name || 'Designer Details');
// Update Open Graph meta tags
useOpenGraph({
title: designer?.name || '',
description: designer?.description ?? (designer ? `${designer.name} - Ride Designer${designer.headquarters_location ? ` based in ${designer.headquarters_location}` : ''}` : ''),
imageUrl: designer?.banner_image_url ?? undefined,
imageId: designer?.banner_image_id ?? undefined,
type: 'profile',
enabled: !!designer
});
useEffect(() => {
if (slug) {
fetchDesignerData();
}
}, [slug]);
// Track page view when designer is loaded
useEffect(() => {
if (designer?.id) {
trackPageView('company', designer.id);
}
}, [designer?.id]);
const fetchDesignerData = async () => {
try {
const { data, error } = await supabase
.from('companies')
.select('*')
.eq('slug', slug || '')
.eq('company_type', 'designer')
.maybeSingle();
if (error) throw error;
setDesigner(data);
if (data) {
fetchStatistics(data.id);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch designer',
metadata: { slug }
});
} finally {
setLoading(false);
}
};
const fetchStatistics = async (designerId: string) => {
try {
// Count rides
const { count: ridesCount, error: ridesError } = await supabase
.from('rides')
.select('id', { count: 'exact', head: true })
.eq('designer_id', designerId);
if (ridesError) throw ridesError;
setTotalRides(ridesCount || 0);
// Count photos
const { count: photosCount, error: photosError } = await supabase
.from('photos')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'designer')
.eq('entity_id', designerId);
if (photosError) throw photosError;
setTotalPhotos(photosCount || 0);
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch designer statistics',
metadata: { designerId }
});
} finally {
setStatsLoading(false);
}
};
const handleEditSubmit = async (data: any) => {
try {
await submitCompanyUpdate(
designer!.id,
data,
user!.id
);
toast({
title: "Edit Submitted",
description: "Your edit has been submitted for review."
});
setIsEditModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
</div>
);
}
if (!designer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Designer Not Found</h1>
<Button onClick={() => navigate('/designers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Designers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/designers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Designers
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this designer")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Designer
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(designer.banner_image_url || designer.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={(getBannerUrls(designer.banner_image_id ?? undefined).mobile || designer.banner_image_url) ?? undefined}
/>
<img
src={(getBannerUrls(designer.banner_image_id ?? undefined).desktop || designer.banner_image_url) ?? undefined}
alt={designer.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : designer.logo_url ? (
<div className="flex items-center justify-center h-full bg-background/90">
<img
src={designer.logo_url}
alt={designer.name}
className="max-h-48 object-contain"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<Ruler className="w-24 h-24 opacity-50" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-end justify-between">
<div>
<Badge variant="outline" className="bg-black/20 text-white border-white/20 mb-2">
Designer
</Badge>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{designer.name}
</h1>
{designer.headquarters_location && (
<div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{designer.headquarters_location}
</div>
)}
<div className="mt-3">
<VersionIndicator
entityType="company"
entityId={designer.id}
entityName={designer.name}
/>
</div>
</div>
{(designer.average_rating ?? 0) > 0 && (
<div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center">
<div className="flex items-center gap-2 text-white mb-2">
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="text-3xl font-bold">
{(designer.average_rating ?? 0).toFixed(1)}
</span>
</div>
<div className="text-white/90 text-sm">
{designer.review_count} {designer.review_count === 1 ? "review" : "reviews"}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Company Info */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-8 max-w-4xl mx-auto">
{designer.founded_year && (
<Card>
<CardContent className="p-4 text-center">
<Calendar className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{designer.founded_year}</div>
<div className="text-sm text-muted-foreground">Founded</div>
</CardContent>
</Card>
)}
{designer.website_url && (
<Card>
<CardContent className="p-4 text-center">
<Globe className="w-6 h-6 text-primary mx-auto mb-2" />
<a
href={designer.website_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline break-all"
>
Visit Website
</a>
</CardContent>
</Card>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {!statsLoading && totalRides > 0 && `(${totalRides})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{designer.description && (
<Card>
<CardContent className="p-6">
<h2 className="text-2xl font-bold mb-4">About</h2>
<p className="text-muted-foreground whitespace-pre-wrap">
{designer.description}
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="rides">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Rides</h2>
<Button
variant="outline"
onClick={() => navigate(`/designers/${designer.slug}/rides`)}
>
View All Rides
</Button>
</div>
<p className="text-muted-foreground">
View all rides designed by {designer.name}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="photos">
<DesignerPhotoGallery
designerId={designer.id}
designerName={designer.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="company"
entityId={designer.id}
entityName={designer.name}
/>
</TabsContent>
</Tabs>
</main>
{/* Edit Modal */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<Suspense fallback={<AdminFormSkeleton />}>
<DesignerForm
initialData={{
id: designer.id,
name: designer.name,
slug: designer.slug,
description: designer.description ?? undefined,
company_type: 'designer',
person_type: (designer.person_type || 'company') as 'company' | 'individual' | 'firm' | 'organization',
website_url: designer.website_url ?? undefined,
founded_year: designer.founded_year ?? undefined,
headquarters_location: designer.headquarters_location ?? undefined,
banner_image_url: designer.banner_image_url ?? undefined,
card_image_url: designer.card_image_url ?? undefined
}}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditModalOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel, Plus } from 'lucide-react';
import { Ride, Company } from '@/types/database';
import { RideSubmissionData } from '@/types/submission-data';
import { supabase } from '@/lib/supabaseClient';
import { RideCard } from '@/components/rides/RideCard';
import { RideForm } from '@/components/admin/RideForm';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function DesignerRides() {
const { designerSlug } = useParams<{ designerSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [designer, setDesigner] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
// Update document title when designer changes
useDocumentTitle(designer ? `${designer.name} - Rides` : 'Designer Rides');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
// Fetch designer
const { data: designerData, error: designerError } = await supabase
.from('companies')
.select('*')
.eq('slug', designerSlug || '')
.eq('company_type', 'designer')
.maybeSingle();
if (designerError) throw designerError;
setDesigner(designerData);
if (designerData) {
// Fetch rides designed by this company
let query = supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*)
`)
.eq('designer_id', designerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
// Supabase returns nullable types, but our Ride type uses undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setRides((ridesData || []) as any);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch designer rides',
metadata: { designerSlug }
});
} finally {
setLoading(false);
}
}, [designerSlug, sortBy, filterCategory, filterStatus]);
useEffect(() => {
if (designerSlug) {
fetchData();
}
}, [designerSlug, fetchData]);
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
useOpenGraph({
title: designer ? `${designer.name} - Rides` : 'Designer Rides',
description: designer
? `Explore ${filteredRides.length} rides designed by ${designer.name}`
: undefined,
imageUrl: designer?.banner_image_url ?? filteredRides[0]?.banner_image_url ?? undefined,
imageId: designer?.banner_image_id ?? filteredRides[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !!designer && !loading
});
const handleCreateSubmit = async (data: any) => {
try {
if (!designer) {
toast({
title: "Error",
description: "Designer information is missing.",
variant: "destructive"
});
return;
}
const submissionData: RideSubmissionData = {
...data,
description: data.description ?? undefined,
designer_id: designer.id,
};
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
// Type assertion needed: RideForm returns data with undefined for optional fields,
// but submitRideCreation expects RideFormData which requires stricter types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await submitRideCreation(submissionData as any, user!.id);
toast({
title: "Ride Submitted",
description: "Your ride submission has been sent for review."
});
setIsCreateModalOpen(false);
fetchData();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to submit ride.";
toast({
title: "Error",
description: message,
variant: "destructive"
});
}
};
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!designer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Designer Not Found</h1>
<Button onClick={() => navigate('/designers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Designers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/designers/${designerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {designer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Rides by {designer.name}</h1>
</div>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a new ride")}
>
<Plus className="w-4 h-4 mr-2" />
Add Ride
</Button>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all rides designed by {designer.name}
</p>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredRides.length} rides
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm px-2 py-0.5">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or park..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredRides.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{filteredRides.map((ride) => (
<RideCard key={ride.id} ride={ride} showParkName={true} />
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
{designer.name} hasn't designed any rides matching your criteria
</p>
</div>
)}
</main>
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
);
}

370
src-old/pages/Designers.tsx Normal file
View File

@@ -0,0 +1,370 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Search, SlidersHorizontal, Ruler, Plus, ChevronDown, Filter, PanelLeftClose, PanelLeftOpen, Grid3X3, List } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { DesignerFilters, DesignerFilterState, defaultDesignerFilters } from '@/components/designers/DesignerFilters';
import { Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { DesignerCard } from '@/components/designers/DesignerCard';
import { DesignerListView } from '@/components/designers/DesignerListView';
import { DesignerForm } from '@/components/admin/DesignerForm';
import { useAuth } from '@/hooks/useAuth';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { submitCompanyCreation } from '@/lib/companyHelpers';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function Designers() {
useDocumentTitle('Designers');
const navigate = useNavigate();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filters, setFilters] = useState<DesignerFilterState>(defaultDesignerFilters);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('designers-sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
const handleCreateSubmit = async (data: any) => {
try {
await submitCompanyCreation(data, 'designer', user!.id);
toast({
title: "Designer Submitted",
description: "Your submission has been sent for review."
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({ title: "Error", description: errorMsg, variant: "destructive" });
}
};
useEffect(() => {
fetchCompanies();
}, [sortBy]);
useEffect(() => {
localStorage.setItem('designers-sidebar-collapsed', JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
const fetchCompanies = async () => {
try {
let query = supabase
.from('companies')
.select('id, name, slug, description, company_type, person_type, logo_url, card_image_url, headquarters_location, founded_year, founded_date, founded_date_precision, average_rating, review_count');
// Filter only designers
query = query.eq('company_type', 'designer');
// Apply sorting
switch (sortBy) {
case 'founded':
query = query.order('founded_year', { ascending: false, nullsFirst: false });
break;
default:
query = query.order('name');
}
const { data } = await query;
setCompanies(data || []);
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch designers',
metadata: { page: 'designers' }
});
} finally {
setLoading(false);
}
};
const filteredCompanies = React.useMemo(() => {
return companies.filter(company => {
// Search filter
const matchesSearch = company.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
company.headquarters_location?.toLowerCase().includes(searchQuery.toLowerCase()) ||
company.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Person type filter
if (filters.personType !== 'all') {
if (filters.personType === 'individual' && company.person_type !== 'individual') {
return false;
}
if (filters.personType === 'company' && company.person_type === 'individual') {
return false;
}
}
// Country filter
if (filters.countries.length > 0) {
if (!company.headquarters_location ||
!filters.countries.some(c => company.headquarters_location?.includes(c))) {
return false;
}
}
// Rating filter
const rating = company.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) {
return false;
}
// Review count filter
const reviewCount = company.review_count || 0;
if (reviewCount < filters.minReviewCount || reviewCount > filters.maxReviewCount) {
return false;
}
// Founded date filter
if (filters.foundedDateFrom || filters.foundedDateTo) {
if (!company.founded_year) {
return false;
}
if (filters.foundedDateFrom && company.founded_year < filters.foundedDateFrom.getFullYear()) {
return false;
}
if (filters.foundedDateTo && company.founded_year > filters.foundedDateTo.getFullYear()) {
return false;
}
}
// Has rating filter
if (filters.hasRating && !company.average_rating) {
return false;
}
// Has founded date filter
if (filters.hasFoundedDate && !company.founded_year) {
return false;
}
// Is individual filter
if (filters.isIndividual && company.person_type !== 'individual') {
return false;
}
return true;
});
}, [companies, searchQuery, filters]);
useOpenGraph({
title: 'Ride Designers - ThrillWiki',
description: `Browse ${filteredCompanies.length} ride designers worldwide`,
imageUrl: filteredCompanies[0]?.banner_image_url ?? undefined,
imageId: filteredCompanies[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !loading
});
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<Ruler className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Designers</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore the designers behind your favorite rides and attractions
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredCompanies.length} designers
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a designer")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Designer
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Desktop: Filter toggle on the left */}
<Button
variant="outline"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="shrink-0 gap-2 hidden lg:flex"
title={sidebarCollapsed ? "Show filters" : "Hide filters"}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
<span>Filters</span>
</Button>
{/* Search bar takes remaining space */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search designers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 h-11"
/>
</div>
</div>
{/* Sort controls - more compact */}
<div className="flex gap-2 w-full lg:w-auto">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px] h-10">
<SlidersHorizontal className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="founded">Founded</SelectItem>
</SelectContent>
</Select>
{/* Mobile filter toggle */}
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="gap-2 lg:hidden"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid"><Grid3X3 className="w-4 h-4" /></TabsTrigger>
<TabsTrigger value="list"><List className="w-4 h-4" /></TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Mobile Filters */}
<div className="lg:hidden">
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent>
<Card className="mt-4">
<CardContent className="pt-6">
<DesignerFilters filters={filters} onFiltersChange={setFilters} designers={companies} />
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Main Content Area with Sidebar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop Filter Sidebar */}
{!sidebarCollapsed && (
<aside className="hidden lg:block flex-shrink-0 transition-all duration-300 lg:w-[340px] xl:w-[380px] 2xl:w-[420px]">
<div className="sticky top-24">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(true)}
title="Hide filters"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<DesignerFilters filters={filters} onFiltersChange={setFilters} designers={companies} />
</CardContent>
</Card>
</div>
</aside>
)}
{/* Results Area */}
<div className="flex-1 min-w-0">
{filteredCompanies.length > 0 ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredCompanies.map((company) => (
<DesignerCard key={company.id} company={company} />
))}
</div>
) : (
<DesignerListView designers={filteredCompanies} onDesignerClick={(d) => navigate(`/designers/${d.slug}`)} />
)
) : (
<div className="text-center py-12">
<Ruler className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No designers found</h3>
<p className="text-muted-foreground">
Try adjusting your search criteria
</p>
</div>
)}
</div>
</div>
</main>
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<DesignerForm onSubmit={handleCreateSubmit} onCancel={() => setIsCreateModalOpen(false)} />
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "@/lib/supabaseClient";
import { authStorage } from "@/lib/authStorage";
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { handleError } from '@/lib/errorHandler';
/**
* ForceLogout - Hidden endpoint for completely clearing auth session
* Access via: /force-logout
* Not linked anywhere in the UI - for manual navigation only
*/
const ForceLogout = () => {
useDocumentTitle('Signing Out');
const navigate = useNavigate();
useEffect(() => {
const performFullLogout = async () => {
try {
// 1. Sign out from Supabase
await supabase.auth.signOut();
// 2. Clear all auth-related storage
authStorage.clearAll();
// 3. Brief delay to ensure cleanup completes
await new Promise(resolve => setTimeout(resolve, 500));
// 4. Redirect to home page
navigate('/', { replace: true });
} catch (error) {
handleError(error, {
action: 'Force logout',
metadata: { operation: 'forceLogout' }
});
// Still redirect even if there's an error
navigate('/', { replace: true });
}
};
performFullLogout();
}, [navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-lg text-muted-foreground">Clearing session...</p>
<p className="text-sm text-muted-foreground">You will be redirected shortly.</p>
</div>
</div>
);
};
export default ForceLogout;

17
src-old/pages/Index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Header } from '@/components/layout/Header';
import { SimpleHeroSearch } from '@/components/homepage/SimpleHeroSearch';
import { ContentTabs } from '@/components/homepage/ContentTabs';
import { MetaTags } from '@/components/seo';
const Index = () => {
return (
<div className="min-h-screen bg-background">
<MetaTags entityType="home" />
<Header />
<SimpleHeroSearch />
<ContentTabs />
</div>
);
};
export default Index;

View File

@@ -0,0 +1,409 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { ArrowLeft, MapPin, Star, Globe, Calendar, Edit, Factory, FerrisWheel } from 'lucide-react';
import { Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { ManufacturerPhotoGallery } from '@/components/companies/ManufacturerPhotoGallery';
import { handleNonCriticalError } from '@/lib/errorHandler';
// Lazy load admin form
const ManufacturerForm = lazy(() => import('@/components/admin/ManufacturerForm').then(m => ({ default: m.ManufacturerForm })));
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { submitCompanyUpdate } from '@/lib/companyHelpers';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { useAuthModal } from '@/hooks/useAuthModal';
import { MetaTags } from '@/components/seo';
export default function ManufacturerDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalRides, setTotalRides] = useState<number>(0);
const [totalModels, setTotalModels] = useState<number>(0);
const [totalPhotos, setTotalPhotos] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
useEffect(() => {
if (slug) {
fetchManufacturerData();
}
}, [slug]);
// Track page view when manufacturer is loaded
useEffect(() => {
if (manufacturer?.id) {
trackPageView('company', manufacturer.id);
}
}, [manufacturer?.id]);
const fetchManufacturerData = async () => {
try {
const { data, error } = await supabase
.from('companies')
.select('*')
.eq('slug', slug || '')
.eq('company_type', 'manufacturer')
.maybeSingle();
if (error) throw error;
setManufacturer(data);
if (data) {
fetchStatistics(data.id);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch manufacturer',
metadata: { slug }
});
} finally {
setLoading(false);
}
};
const fetchStatistics = async (manufacturerId: string) => {
try {
// Count rides
const { count: ridesCount, error: ridesError } = await supabase
.from('rides')
.select('id', { count: 'exact', head: true })
.eq('manufacturer_id', manufacturerId);
if (ridesError) throw ridesError;
setTotalRides(ridesCount || 0);
// Count models
const { count: modelsCount, error: modelsError } = await supabase
.from('ride_models')
.select('id', { count: 'exact', head: true })
.eq('manufacturer_id', manufacturerId);
if (modelsError) throw modelsError;
setTotalModels(modelsCount || 0);
// Count photos
const { count: photosCount, error: photosError } = await supabase
.from('photos')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'manufacturer')
.eq('entity_id', manufacturerId);
if (photosError) throw photosError;
setTotalPhotos(photosCount || 0);
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch manufacturer statistics',
metadata: { manufacturerId }
});
} finally {
setStatsLoading(false);
}
};
const handleEditSubmit = async (data: any) => {
try {
await submitCompanyUpdate(
manufacturer!.id,
data,
user!.id
);
toast({
title: "Edit Submitted",
description: "Your edit has been submitted for review."
});
setIsEditModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
</div>
);
}
if (!manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Manufacturer Not Found</h1>
<Button onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Manufacturers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<MetaTags entityType="company" entitySlug={slug} />
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
<span className="md:hidden">Back</span>
<span className="hidden md:inline">Back to Manufacturers</span>
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this manufacturer")}
>
<Edit className="w-4 h-4 mr-2" />
<span className="md:hidden">Edit</span>
<span className="hidden md:inline">Edit Manufacturer</span>
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-4 md:mb-8">
<div className="aspect-[16/9] md:aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(manufacturer.banner_image_url || manufacturer.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={(getBannerUrls(manufacturer.banner_image_id ?? undefined).mobile || manufacturer.banner_image_url) ?? undefined}
/>
<img
src={(getBannerUrls(manufacturer.banner_image_id ?? undefined).desktop || manufacturer.banner_image_url) ?? undefined}
alt={manufacturer.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : manufacturer.logo_url ? (
<div className="flex items-center justify-center h-full bg-background/90">
<img
src={manufacturer.logo_url}
alt={manufacturer.name}
className="max-h-48 object-contain"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<Factory className="w-24 h-24 opacity-50" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-6 pb-12 md:p-8 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-end justify-between">
<div>
<Badge variant="outline" className="bg-black/20 text-white border-white/20 mb-2">
Manufacturer
</Badge>
<h1 className="text-2xl md:text-4xl lg:text-6xl font-bold text-white mb-2">
{manufacturer.name}
</h1>
{manufacturer.headquarters_location && (
<div className="flex items-center text-white/90 text-sm md:text-lg">
<MapPin className="w-5 h-5 mr-2" />
{manufacturer.headquarters_location}
</div>
)}
<div className="mt-3">
<VersionIndicator
entityType="company"
entityId={manufacturer.id}
entityName={manufacturer.name}
/>
</div>
</div>
{(manufacturer.average_rating ?? 0) > 0 && (
<div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center">
<div className="flex items-center gap-2 text-white mb-2">
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="text-3xl font-bold">
{(manufacturer.average_rating ?? 0).toFixed(1)}
</span>
</div>
<div className="text-white/90 text-sm">
{manufacturer.review_count} {manufacturer.review_count === 1 ? "review" : "reviews"}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Company Info */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-8 max-w-4xl mx-auto">
{manufacturer.founded_year && (
<Card>
<CardContent className="p-3 md:p-4 text-center">
<Calendar className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{manufacturer.founded_year}</div>
<div className="text-sm text-muted-foreground">Founded</div>
</CardContent>
</Card>
)}
{manufacturer.website_url && (
<Card>
<CardContent className="p-3 md:p-4 text-center">
<Globe className="w-6 h-6 text-primary mx-auto mb-2" />
<a
href={manufacturer.website_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline break-all"
>
Visit Website
</a>
</CardContent>
</Card>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {!statsLoading && totalRides > 0 && `(${totalRides})`}
</TabsTrigger>
<TabsTrigger value="models">
Models {!statsLoading && totalModels > 0 && `(${totalModels})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{manufacturer.description && (
<Card>
<CardContent className="p-6">
<h2 className="text-2xl font-bold mb-4">About</h2>
<p className="text-muted-foreground whitespace-pre-wrap">
{manufacturer.description}
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="rides">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Rides</h2>
<Button
variant="outline"
onClick={() => navigate(`/manufacturers/${manufacturer.slug}/rides`)}
>
View All Rides
</Button>
</div>
<p className="text-muted-foreground">
View all rides manufactured by {manufacturer.name}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Models</h2>
<Button
variant="outline"
onClick={() => navigate(`/manufacturers/${manufacturer.slug}/models`)}
>
View All Models
</Button>
</div>
<p className="text-muted-foreground">
View all ride models by {manufacturer.name}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="photos">
<ManufacturerPhotoGallery
manufacturerId={manufacturer.id}
manufacturerName={manufacturer.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="company"
entityId={manufacturer.id}
entityName={manufacturer.name}
/>
</TabsContent>
</Tabs>
</main>
{/* Edit Modal */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<Suspense fallback={<AdminFormSkeleton />}>
<ManufacturerForm
initialData={{
id: manufacturer.id,
name: manufacturer.name,
slug: manufacturer.slug,
description: manufacturer.description ?? undefined,
company_type: 'manufacturer',
person_type: (manufacturer.person_type || 'company') as 'company' | 'individual' | 'firm' | 'organization',
website_url: manufacturer.website_url ?? undefined,
founded_year: manufacturer.founded_year ?? undefined,
headquarters_location: manufacturer.headquarters_location ?? undefined,
banner_image_url: manufacturer.banner_image_url ?? undefined,
card_image_url: manufacturer.card_image_url ?? undefined
}}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditModalOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel, Plus } from 'lucide-react';
import { RideModel, Company, Park } from '@/types/database';
import { RideModelSubmissionData } from '@/types/submission-data';
import { supabase } from '@/lib/supabaseClient';
import { RideModelCard } from '@/components/rides/RideModelCard';
import { RideModelForm } from '@/components/admin/RideModelForm';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
interface RideModelWithCount extends RideModel {
ride_count: number;
}
export default function ManufacturerModels() {
const { manufacturerSlug } = useParams<{ manufacturerSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [models, setModels] = useState<RideModelWithCount[]>([]);
const [loading, setLoading] = useState(true);
// Update document title when manufacturer changes
useDocumentTitle(manufacturer ? `${manufacturer.name} - Models` : 'Manufacturer Models');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
// Fetch manufacturer
const { data: manufacturerData, error: manufacturerError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug || '')
.eq('company_type', 'manufacturer')
.maybeSingle();
if (manufacturerError) throw manufacturerError;
setManufacturer(manufacturerData);
if (manufacturerData) {
// Fetch ride models with ride count
let query = supabase
.from('ride_models')
.select(`
*,
rides:rides(count)
`)
.eq('manufacturer_id', manufacturerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
switch (sortBy) {
case 'name':
query = query.order('name');
break;
default:
query = query.order('name');
}
const { data: modelsData, error: modelsError } = await query;
if (modelsError) throw modelsError;
// Transform data to include ride count
const modelsWithCounts: RideModelWithCount[] = (modelsData || []).map(model => ({
...model,
ride_count: Array.isArray(model.rides) ? model.rides[0]?.count || 0 : 0
})) as RideModelWithCount[];
setModels(modelsWithCounts);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch manufacturer models',
metadata: { manufacturerSlug }
});
} finally {
setLoading(false);
}
}, [manufacturerSlug, sortBy, filterCategory]);
useEffect(() => {
if (manufacturerSlug) {
fetchData();
}
}, [manufacturerSlug, fetchData]);
const filteredModels = models.filter(model =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
useOpenGraph({
title: manufacturer ? `${manufacturer.name} - Ride Models` : 'Manufacturer Models',
description: manufacturer
? `Browse ${filteredModels.length} ride models by ${manufacturer.name}`
: undefined,
imageUrl: manufacturer?.banner_image_url || filteredModels[0]?.banner_image_url,
imageId: manufacturer?.banner_image_id || filteredModels[0]?.banner_image_id,
type: 'website',
enabled: !!manufacturer && !loading
});
const handleCreateSubmit = async (data: any) => {
try {
if (!manufacturer) {
toast({
title: "Error",
description: "Manufacturer information is missing.",
variant: "destructive"
});
return;
}
const submissionData: RideModelSubmissionData = {
...data,
manufacturer_id: manufacturer.id,
ride_type: data.ride_type ?? undefined,
};
const { submitRideModelCreation } = await import('@/lib/entitySubmissionHelpers');
await submitRideModelCreation(submissionData as any, user!.id);
toast({
title: "Ride Model Submitted",
description: "Your ride model submission has been sent for review."
});
setIsCreateModalOpen(false);
fetchData();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to submit ride model.";
toast({
title: "Error",
description: message,
variant: "destructive"
});
}
};
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Manufacturer Not Found</h1>
<Button onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Manufacturers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Models by {manufacturer.name}</h1>
</div>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a ride model")}
>
<Plus className="w-4 h-4 mr-2" />
Add Ride Model
</Button>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all ride models manufactured by {manufacturer.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredModels.length} models
</Badge>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search models by name..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredModels.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{filteredModels.map((model) => (
<RideModelCard
key={model.id}
model={model}
manufacturerSlug={manufacturerSlug || ''}
/>
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No models found</h3>
<p className="text-muted-foreground">
{manufacturer.name} doesn't have any models matching your criteria
</p>
</div>
)}
</main>
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideModelForm
manufacturerName={manufacturer.name}
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel, Plus } from 'lucide-react';
import { Ride, Company } from '@/types/database';
import { RideSubmissionData } from '@/types/submission-data';
import { supabase } from '@/lib/supabaseClient';
import { RideCard } from '@/components/rides/RideCard';
import { RideForm } from '@/components/admin/RideForm';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function ManufacturerRides() {
const { manufacturerSlug } = useParams<{ manufacturerSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
// Update document title when manufacturer changes
useDocumentTitle(manufacturer ? `${manufacturer.name} - Rides` : 'Manufacturer Rides');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
// Fetch manufacturer
const { data: manufacturerData, error: manufacturerError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug || '')
.eq('company_type', 'manufacturer')
.maybeSingle();
if (manufacturerError) throw manufacturerError;
setManufacturer(manufacturerData);
if (manufacturerData) {
// Fetch rides manufactured by this company
let query = supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*)
`)
.eq('manufacturer_id', manufacturerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
// Supabase returns nullable types, but our Ride type uses undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setRides((ridesData || []) as any);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch manufacturer rides',
metadata: { manufacturerSlug }
});
} finally {
setLoading(false);
}
}, [manufacturerSlug, sortBy, filterCategory, filterStatus]);
useEffect(() => {
if (manufacturerSlug) {
fetchData();
}
}, [manufacturerSlug, fetchData]);
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
useOpenGraph({
title: manufacturer ? `${manufacturer.name} - Rides` : 'Manufacturer Rides',
description: manufacturer
? `Explore ${filteredRides.length} rides manufactured by ${manufacturer.name}`
: undefined,
imageUrl: manufacturer?.banner_image_url ?? filteredRides[0]?.banner_image_url ?? undefined,
imageId: manufacturer?.banner_image_id ?? filteredRides[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !!manufacturer && !loading
});
const handleCreateSubmit = async (data: any) => {
try {
if (!manufacturer) {
toast({
title: "Error",
description: "Manufacturer information is missing.",
variant: "destructive"
});
return;
}
const submissionData: RideSubmissionData = {
...data,
manufacturer_id: manufacturer.id,
description: data.description ?? undefined,
};
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
// Type assertion needed: RideForm returns RideFormData with undefined for optional fields,
// but submitRideCreation expects specific form data structure
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await submitRideCreation(submissionData as any, user!.id);
toast({
title: "Ride Submitted",
description: "Your ride submission has been sent for review."
});
setIsCreateModalOpen(false);
fetchData();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to submit ride.";
toast({
title: "Error",
description: message,
variant: "destructive"
});
}
};
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Manufacturer Not Found</h1>
<Button onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Manufacturers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Rides by {manufacturer.name}</h1>
</div>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a new ride")}
>
<Plus className="w-4 h-4 mr-2" />
Add Ride
</Button>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all rides manufactured by {manufacturer.name}
</p>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredRides.length} rides
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm px-2 py-0.5">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or park..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredRides.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{filteredRides.map((ride) => (
<RideCard key={ride.id} ride={ride} showParkName={true} />
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
{manufacturer.name} hasn't manufactured any rides matching your criteria
</p>
</div>
)}
</main>
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Search, SlidersHorizontal, Factory, Plus, ChevronDown, Filter, PanelLeftClose, PanelLeftOpen, Grid3X3, List } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { ManufacturerFilters, ManufacturerFilterState, defaultManufacturerFilters } from '@/components/manufacturers/ManufacturerFilters';
import { Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { ManufacturerCard } from '@/components/manufacturers/ManufacturerCard';
import { ManufacturerListView } from '@/components/manufacturers/ManufacturerListView';
import { ManufacturerForm } from '@/components/admin/ManufacturerForm';
import { useAuth } from '@/hooks/useAuth';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { submitCompanyCreation } from '@/lib/companyHelpers';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function Manufacturers() {
useDocumentTitle('Manufacturers');
const navigate = useNavigate();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filters, setFilters] = useState<ManufacturerFilterState>(defaultManufacturerFilters);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('manufacturers-sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
useEffect(() => {
fetchCompanies();
}, [sortBy]);
useEffect(() => {
localStorage.setItem('manufacturers-sidebar-collapsed', JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
const fetchCompanies = async () => {
try {
let query = supabase
.from('companies')
.select('id, name, slug, description, company_type, person_type, logo_url, card_image_url, headquarters_location, founded_year, founded_date, founded_date_precision, average_rating, review_count');
// Filter only manufacturers
query = query.eq('company_type', 'manufacturer');
// Apply sorting
switch (sortBy) {
case 'founded':
query = query.order('founded_year', { ascending: false, nullsFirst: false });
break;
default:
query = query.order('name');
}
const { data } = await query;
setCompanies(data || []);
} catch (error) {
handleNonCriticalError(error, {
action: 'Fetch manufacturers',
metadata: { page: 'manufacturers' }
});
} finally {
setLoading(false);
}
};
const filteredCompanies = React.useMemo(() => {
return companies.filter(company => {
// Search filter
const matchesSearch = company.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
company.headquarters_location?.toLowerCase().includes(searchQuery.toLowerCase()) ||
company.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Person type filter
if (filters.personType !== 'all') {
if (filters.personType === 'individual' && company.person_type !== 'individual') {
return false;
}
if (filters.personType === 'company' && company.person_type === 'individual') {
return false;
}
}
// Country filter
if (filters.countries.length > 0) {
if (!company.headquarters_location ||
!filters.countries.some(c => company.headquarters_location?.includes(c))) {
return false;
}
}
// Rating filter
const rating = company.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) {
return false;
}
// Review count filter
const reviewCount = company.review_count || 0;
if (reviewCount < filters.minReviewCount || reviewCount > filters.maxReviewCount) {
return false;
}
// Founded date filter
if (filters.foundedDateFrom || filters.foundedDateTo) {
if (!company.founded_year) {
return false;
}
if (filters.foundedDateFrom && company.founded_year < filters.foundedDateFrom.getFullYear()) {
return false;
}
if (filters.foundedDateTo && company.founded_year > filters.foundedDateTo.getFullYear()) {
return false;
}
}
// Has rating filter
if (filters.hasRating && !company.average_rating) {
return false;
}
// Has founded date filter
if (filters.hasFoundedDate && !company.founded_year) {
return false;
}
// Is individual filter
if (filters.isIndividual && company.person_type !== 'individual') {
return false;
}
return true;
});
}, [companies, searchQuery, filters]);
useOpenGraph({
title: 'Ride Manufacturers - ThrillWiki',
description: `Browse ${filteredCompanies.length} ride manufacturers worldwide`,
imageUrl: filteredCompanies[0]?.banner_image_url ?? undefined,
imageId: filteredCompanies[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !loading
});
const handleCreateSubmit = async (data: any) => {
try {
await submitCompanyCreation(
data,
'manufacturer',
user!.id
);
toast({
title: "Manufacturer Submitted",
description: "Your submission has been sent for review."
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<Factory className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Manufacturers</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore the manufacturers behind your favorite rides and attractions
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredCompanies.length} manufacturers
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a manufacturer")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Manufacturer
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Desktop: Filter toggle on the left */}
<Button
variant="outline"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="shrink-0 gap-2 hidden lg:flex"
title={sidebarCollapsed ? "Show filters" : "Hide filters"}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
<span>Filters</span>
</Button>
{/* Search bar takes remaining space */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search manufacturers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 h-11"
/>
</div>
</div>
{/* Sort controls - more compact */}
<div className="flex gap-2 w-full lg:w-auto">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px] h-10">
<SlidersHorizontal className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="founded">Founded</SelectItem>
</SelectContent>
</Select>
{/* Mobile filter toggle */}
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="gap-2 lg:hidden"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid"><Grid3X3 className="w-4 h-4" /></TabsTrigger>
<TabsTrigger value="list"><List className="w-4 h-4" /></TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Mobile Filters */}
<div className="lg:hidden">
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent>
<Card className="mt-4">
<CardContent className="pt-6">
<ManufacturerFilters filters={filters} onFiltersChange={setFilters} manufacturers={companies} />
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Main Content Area with Sidebar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop Filter Sidebar */}
{!sidebarCollapsed && (
<aside className="hidden lg:block flex-shrink-0 transition-all duration-300 lg:w-[340px] xl:w-[380px] 2xl:w-[420px]">
<div className="sticky top-24">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(true)}
title="Hide filters"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<ManufacturerFilters filters={filters} onFiltersChange={setFilters} manufacturers={companies} />
</CardContent>
</Card>
</div>
</aside>
)}
{/* Results Area */}
<div className="flex-1 min-w-0">
{filteredCompanies.length > 0 ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredCompanies.map((company) => (
<ManufacturerCard key={company.id} company={company} />
))}
</div>
) : (
<ManufacturerListView manufacturers={filteredCompanies} onManufacturerClick={(m) => navigate(`/manufacturers/${m.slug}`)} />
)
) : (
<div className="text-center py-12">
<Factory className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No manufacturers found</h3>
<p className="text-muted-foreground">
Try adjusting your search criteria
</p>
</div>
)}
</div>
</div>
</main>
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<ManufacturerForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { logger } from '@/lib/logger';
const NotFound = () => {
useDocumentTitle('404 - Page Not Found');
const location = useLocation();
useEffect(() => {
logger.error("404 Error: User attempted to access non-existent route", { pathname: location.pathname });
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-gray-600">Oops! Page not found</p>
<a href="/" className="text-blue-500 underline hover:text-blue-700">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;

View File

@@ -0,0 +1,468 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { ArrowLeft, MapPin, Star, Globe, Calendar, Edit, FerrisWheel, Gauge } from 'lucide-react';
import { Company, Park } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { OperatorPhotoGallery } from '@/components/companies/OperatorPhotoGallery';
import { ParkCard } from '@/components/parks/ParkCard';
import { handleNonCriticalError } from '@/lib/errorHandler';
// Lazy load admin form
const OperatorForm = lazy(() => import('@/components/admin/OperatorForm').then(m => ({ default: m.OperatorForm })));
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { submitCompanyUpdate } from '@/lib/companyHelpers';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function OperatorDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [operator, setOperator] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalParks, setTotalParks] = useState<number>(0);
const [operatingRides, setOperatingRides] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const [totalPhotos, setTotalPhotos] = useState<number>(0);
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
// Update document title when operator changes
useDocumentTitle(operator?.name || 'Operator Details');
// Update Open Graph meta tags
useOpenGraph({
title: operator?.name || '',
description: operator?.description ?? (operator ? `${operator.name} - Park Operator${operator.headquarters_location ? ` based in ${operator.headquarters_location}` : ''}` : undefined),
imageUrl: operator?.banner_image_url ?? undefined,
imageId: operator?.banner_image_id ?? undefined,
type: 'profile',
enabled: !!operator
});
useEffect(() => {
if (slug) {
fetchOperatorData();
}
}, [slug]);
// Track page view when operator is loaded
useEffect(() => {
if (operator?.id) {
trackPageView('company', operator.id);
}
}, [operator?.id]);
const fetchOperatorData = async () => {
try {
const { data, error } = await supabase
.from('companies')
.select('*')
.eq('slug', slug || '')
.eq('company_type', 'operator')
.maybeSingle();
if (error) throw error;
setOperator(data);
// Fetch parks operated by this operator
if (data) {
fetchParks(data.id);
fetchStatistics(data.id);
fetchPhotoCount(data.id);
}
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Operator', metadata: { slug } });
} finally {
setLoading(false);
}
};
const fetchParks = async (operatorId: string) => {
try {
const { data, error } = await supabase
.from('parks')
.select(`
*,
location:locations(*)
`)
.eq('operator_id', operatorId)
.order('name')
.limit(6);
if (error) throw error;
setParks(data || []);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Operator Parks', metadata: { operatorId } });
} finally {
setParksLoading(false);
}
};
const fetchStatistics = async (operatorId: string) => {
try {
// Get total parks count
const { count: parksCount, error: parksError } = await supabase
.from('parks')
.select('id', { count: 'exact', head: true })
.eq('operator_id', operatorId);
if (parksError) throw parksError;
setTotalParks(parksCount || 0);
// Get operating rides count across all parks
const { data: ridesData, error: ridesError } = await supabase
.from('rides')
.select('id, parks!inner(operator_id)')
.eq('parks.operator_id', operatorId)
.eq('status', 'operating');
if (ridesError) throw ridesError;
setOperatingRides(ridesData?.length || 0);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Operator Statistics', metadata: { operatorId } });
} finally {
setStatsLoading(false);
}
};
const fetchPhotoCount = async (operatorId: string) => {
try {
const { count, error } = await supabase
.from('photos')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'operator')
.eq('entity_id', operatorId);
if (error) throw error;
setTotalPhotos(count || 0);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Operator Photo Count', metadata: { operatorId } });
setTotalPhotos(0);
}
};
const handleEditSubmit = async (data: any) => {
try {
await submitCompanyUpdate(
operator!.id,
data,
user!.id
);
toast({
title: "Edit Submitted",
description: "Your edit has been submitted for review."
});
setIsEditModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
</div>
);
}
if (!operator) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Operator Not Found</h1>
<Button onClick={() => navigate('/operators')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Operators
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/operators')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Operators
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this operator")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Operator
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(operator.banner_image_url || operator.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={getBannerUrls(operator.banner_image_id ?? undefined).mobile ?? operator.banner_image_url ?? undefined}
/>
<img
src={getBannerUrls(operator.banner_image_id ?? undefined).desktop ?? operator.banner_image_url ?? undefined}
alt={operator.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : operator.logo_url ? (
<div className="flex items-center justify-center h-full bg-background/90">
<img
src={operator.logo_url}
alt={operator.name}
className="max-h-48 object-contain"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<FerrisWheel className="w-24 h-24 opacity-50" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-end justify-between">
<div>
<Badge variant="outline" className="bg-black/20 text-white border-white/20 mb-2">
Operator
</Badge>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{operator.name}
</h1>
{operator.headquarters_location && (
<div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{operator.headquarters_location}
</div>
)}
<div className="mt-3">
<VersionIndicator
entityType="company"
entityId={operator.id}
entityName={operator.name}
/>
</div>
</div>
{(operator.average_rating ?? 0) > 0 && (
<div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center">
<div className="flex items-center gap-2 text-white mb-2">
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="text-3xl font-bold">
{(operator.average_rating ?? 0).toFixed(1)}
</span>
</div>
<div className="text-white/90 text-sm">
{operator.review_count} {operator.review_count === 1 ? "review" : "reviews"}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Company Info */}
<div className="flex flex-wrap justify-center gap-4 mb-8">
{!statsLoading && totalParks > 0 && (
<Card>
<CardContent className="p-4 text-center">
<FerrisWheel className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{totalParks}</div>
<div className="text-sm text-muted-foreground">
{totalParks === 1 ? 'Park Operated' : 'Parks Operated'}
</div>
</CardContent>
</Card>
)}
{!statsLoading && operatingRides > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Gauge className="w-6 h-6 text-accent mx-auto mb-2" />
<div className="text-2xl font-bold">{operatingRides}</div>
<div className="text-sm text-muted-foreground">
Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
</div>
</CardContent>
</Card>
)}
{operator.founded_year && (
<Card>
<CardContent className="p-4 text-center">
<Calendar className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{operator.founded_year}</div>
<div className="text-sm text-muted-foreground">Founded</div>
</CardContent>
</Card>
)}
{operator.website_url && (
<Card>
<CardContent className="p-4 text-center">
<Globe className="w-6 h-6 text-primary mx-auto mb-2" />
<a
href={operator.website_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline break-all"
>
Visit Website
</a>
</CardContent>
</Card>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="parks">
Parks {!statsLoading && totalParks > 0 && `(${totalParks})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{operator.description && (
<Card>
<CardContent className="p-6">
<h2 className="text-2xl font-bold mb-4">About</h2>
<p className="text-muted-foreground whitespace-pre-wrap">
{operator.description}
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="parks">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Parks Operated</h2>
<Button
variant="outline"
onClick={() => navigate(`/operators/${operator.slug}/parks`)}
>
View All Parks
</Button>
</div>
{parksLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
</div>
) : parks.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<FerrisWheel className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No parks found for {operator.name}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="photos">
<OperatorPhotoGallery
operatorId={operator.id}
operatorName={operator.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="company"
entityId={operator.id}
entityName={operator.name}
/>
</TabsContent>
</Tabs>
</main>
{/* Edit Modal */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<Suspense fallback={<AdminFormSkeleton />}>
<OperatorForm
initialData={{
id: operator.id,
name: operator.name,
slug: operator.slug,
description: operator.description ?? undefined,
company_type: 'operator',
person_type: (operator.person_type || 'company') as 'company' | 'individual' | 'firm' | 'organization',
website_url: operator.website_url ?? undefined,
founded_year: operator.founded_year ?? undefined,
headquarters_location: operator.headquarters_location ?? undefined,
banner_image_url: operator.banner_image_url ?? undefined,
card_image_url: operator.card_image_url ?? undefined
}}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditModalOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,295 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, MapPin, Filter, FerrisWheel } from 'lucide-react';
import { Park, Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { ParkGridView } from '@/components/parks/ParkGridView';
import { ParkListView } from '@/components/parks/ParkListView';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ParkSortOptions } from '@/components/parks/ParkSortOptions';
import { ParkFilters } from '@/components/parks/ParkFilters';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Grid3X3, List } from 'lucide-react';
import { FilterState, SortState } from './Parks';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { handleNonCriticalError } from '@/lib/errorHandler';
const initialFilters: FilterState = {
search: '',
parkType: [],
status: [],
country: [],
minRating: 0,
maxRating: 5,
minRides: 0,
maxRides: 1000,
openingYearStart: null,
openingYearEnd: null,
};
const initialSort: SortState = {
field: 'name',
direction: 'asc'
};
export default function OperatorParks() {
const { operatorSlug } = useParams<{ operatorSlug: string }>();
const navigate = useNavigate();
const [operator, setOperator] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
// Update document title when operator changes
useDocumentTitle(operator ? `${operator.name} - Parks` : 'Operator Parks');
useEffect(() => {
if (operatorSlug) {
fetchData();
}
}, [operatorSlug]);
const fetchData = async () => {
try {
// Fetch operator
const { data: operatorData, error: operatorError } = await supabase
.from('companies')
.select('*')
.eq('slug', operatorSlug || '')
.eq('company_type', 'operator')
.maybeSingle();
if (operatorError) throw operatorError;
setOperator(operatorData);
if (operatorData) {
// Fetch parks operated by this operator
const { data: parksData, error: parksError } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*),
property_owner:companies!parks_property_owner_id_fkey(*)
`)
.eq('operator_id', operatorData.id)
.order('name');
if (parksError) throw parksError;
setParks(parksData || []);
}
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Operator Parks Data', metadata: { operatorSlug } });
} finally {
setLoading(false);
}
};
const filteredAndSortedParks = useMemo(() => {
let filtered = parks.filter(park => {
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const matchesSearch =
park.name.toLowerCase().includes(searchTerm) ||
park.description?.toLowerCase().includes(searchTerm) ||
park.location?.city?.toLowerCase().includes(searchTerm) ||
park.location?.country?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
if (filters.parkType.length > 0 && !filters.parkType.includes(park.park_type)) return false;
if (filters.status.length > 0 && !filters.status.includes(park.status)) return false;
if (filters.country.length > 0 && !filters.country.includes(park.location?.country || '')) return false;
const rating = park.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) return false;
const rideCount = park.ride_count || 0;
if (rideCount < filters.minRides || rideCount > filters.maxRides) return false;
return true;
});
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sort.field) {
case 'name':
aValue = a.name;
bValue = b.name;
break;
case 'rating':
aValue = a.average_rating || 0;
bValue = b.average_rating || 0;
break;
case 'rides':
aValue = a.ride_count || 0;
bValue = b.ride_count || 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sort.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return sort.direction === 'asc' ? aValue - bValue : bValue - aValue;
});
return filtered;
}, [parks, filters, sort]);
useOpenGraph({
title: operator ? `Parks by ${operator.name} - ThrillWiki` : 'Operator Parks',
description: operator
? `Explore ${filteredAndSortedParks.length} theme parks operated by ${operator.name}`
: undefined,
imageUrl: operator?.banner_image_url ?? filteredAndSortedParks[0]?.banner_image_url ?? undefined,
imageId: operator?.banner_image_id ?? filteredAndSortedParks[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !!operator && !loading
});
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!operator) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Operator Not Found</h1>
<Button onClick={() => navigate('/operators')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Operators
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/operators/${operatorSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {operator.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Parks Operated by {operator.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all theme parks operated by {operator.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredAndSortedParks.length} parks
</Badge>
</div>
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<ParkSearch
value={filters.search}
onChange={(search) => setFilters(prev => ({ ...prev, search }))}
/>
</div>
<div className="flex gap-2 w-full lg:w-auto">
<div className="flex-1 sm:flex-none">
<ParkSortOptions
sort={sort}
onSortChange={setSort}
/>
</div>
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="shrink-0"
>
<Filter className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">Filters</span>
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{showFilters && (
<Card>
<CardContent className="p-6">
<ParkFilters
filters={filters}
onFiltersChange={setFilters}
parks={parks}
/>
</CardContent>
</Card>
)}
</div>
{filteredAndSortedParks.length > 0 ? (
viewMode === 'grid' ? (
<ParkGridView parks={filteredAndSortedParks} />
) : (
<ParkListView
parks={filteredAndSortedParks}
onParkClick={(park) => navigate(`/parks/${park.slug}`)}
/>
)
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No parks found</h3>
<p className="text-muted-foreground">
{operator.name} doesn't operate any parks matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

394
src-old/pages/Operators.tsx Normal file
View File

@@ -0,0 +1,394 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Search, Filter, Building, Plus, ChevronDown, PanelLeftClose, PanelLeftOpen, Grid3X3, List } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { supabase } from '@/lib/supabaseClient';
import OperatorCard from '@/components/operators/OperatorCard';
import { OperatorListView } from '@/components/operators/OperatorListView';
import { OperatorForm } from '@/components/admin/OperatorForm';
import { OperatorFilters, OperatorFilterState, defaultOperatorFilters } from '@/components/operators/OperatorFilters';
import { Company } from '@/types/database';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { submitCompanyCreation } from '@/lib/companyHelpers';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
const Operators = () => {
useDocumentTitle('Operators');
const navigate = useNavigate();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filters, setFilters] = useState<OperatorFilterState>(defaultOperatorFilters);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('operators-sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
useEffect(() => {
localStorage.setItem('operators-sidebar-collapsed', JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
const { data: operators, isLoading } = useQuery({
queryKey: ['operators'],
queryFn: async () => {
// Get companies that are park operators with park counts
const { data, error } = await supabase
.from('companies')
.select(`
*,
parks:parks!operator_id(count)
`)
.in('id',
await supabase
.from('parks')
.select('operator_id')
.not('operator_id', 'is', null)
.then(({ data }) => data?.map(park => park.operator_id).filter((id): id is string => id !== null) || [])
)
.order('name');
if (error) throw error;
// Transform the data to include park_count
const transformedData = data?.map(company => ({
...company,
park_count: company.parks?.[0]?.count || 0
})) || [];
return transformedData as (Company & { park_count: number })[];
},
});
const handleCreateSubmit = async (data: any) => {
try {
await submitCompanyCreation(
data,
'operator',
user!.id
);
toast({
title: "Operator Submitted",
description: "Your submission has been sent for review."
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
const filteredAndSortedOperators = React.useMemo(() => {
if (!operators) return [];
let filtered = operators.filter(operator => {
// Search filter
const matchesSearch = operator.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(operator.description && operator.description.toLowerCase().includes(searchTerm.toLowerCase()));
if (!matchesSearch) return false;
// Country filter
if (filters.countries.length > 0) {
if (!operator.headquarters_location || !filters.countries.some(c => operator.headquarters_location?.includes(c))) {
return false;
}
}
// Rating filter
const rating = operator.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) {
return false;
}
// Review count filter
const reviewCount = operator.review_count || 0;
if (reviewCount < filters.minReviewCount || reviewCount > filters.maxReviewCount) {
return false;
}
// Park count filter
const parkCount = operator.park_count || 0;
if (parkCount < filters.minParkCount || parkCount > filters.maxParkCount) {
return false;
}
// Founded date filter
if (filters.foundedDateFrom || filters.foundedDateTo) {
if (!operator.founded_year) {
return false;
}
if (filters.foundedDateFrom && operator.founded_year < filters.foundedDateFrom.getFullYear()) {
return false;
}
if (filters.foundedDateTo && operator.founded_year > filters.foundedDateTo.getFullYear()) {
return false;
}
}
// Has rating filter
if (filters.hasRating && !operator.average_rating) {
return false;
}
// Has founded date filter
if (filters.hasFoundedDate && !operator.founded_year) {
return false;
}
return true;
});
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'rating':
return (b.average_rating || 0) - (a.average_rating || 0);
case 'founded':
return (b.founded_year || 0) - (a.founded_year || 0);
case 'reviews':
return (b.review_count || 0) - (a.review_count || 0);
default:
return 0;
}
});
return filtered;
}, [operators, searchTerm, sortBy, filters]);
useOpenGraph({
title: 'Park Operators - ThrillWiki',
description: `Browse ${filteredAndSortedOperators.length} theme park operators worldwide`,
imageUrl: filteredAndSortedOperators[0]?.banner_image_url ?? undefined,
imageId: filteredAndSortedOperators[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !isLoading
});
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<Building className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Park Operators</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Discover companies that operate theme parks
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredAndSortedOperators?.length || 0} operators
</Badge>
{searchTerm && (
<Badge variant="outline" className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap">
Searching: "{searchTerm}"
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add an operator")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Operator
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Desktop: Filter toggle on the left */}
<Button
variant="outline"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="shrink-0 gap-2 hidden lg:flex"
title={sidebarCollapsed ? "Show filters" : "Hide filters"}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
<span>Filters</span>
</Button>
{/* Search bar takes remaining space */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search park operators..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11"
/>
</div>
</div>
{/* Sort controls - more compact */}
<div className="flex gap-2 w-full lg:w-auto">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
<SelectItem value="founded">Founded</SelectItem>
<SelectItem value="reviews">Reviews</SelectItem>
</SelectContent>
</Select>
{/* Mobile filter toggle */}
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="gap-2 lg:hidden"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid"><Grid3X3 className="w-4 h-4" /></TabsTrigger>
<TabsTrigger value="list"><List className="w-4 h-4" /></TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Mobile Filters */}
<div className="lg:hidden">
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent>
<Card className="mt-4">
<CardContent className="pt-6">
<OperatorFilters filters={filters} onFiltersChange={setFilters} operators={operators || []} />
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Main Content Area with Sidebar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop Filter Sidebar */}
{!sidebarCollapsed && (
<aside className="hidden lg:block flex-shrink-0 transition-all duration-300 lg:w-[340px] xl:w-[380px] 2xl:w-[420px]">
<div className="sticky top-24">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(true)}
title="Hide filters"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<OperatorFilters filters={filters} onFiltersChange={setFilters} operators={operators || []} />
</CardContent>
</Card>
</div>
</aside>
)}
{/* Results Area */}
<div className="flex-1 min-w-0">
{/* Loading State */}
{isLoading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-4">Loading park operators...</p>
</div>
)}
{/* Operators Grid/List */}
{!isLoading && filteredAndSortedOperators && filteredAndSortedOperators.length > 0 && (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredAndSortedOperators.map((operator) => (
<OperatorCard key={operator.id} company={operator} />
))}
</div>
) : (
<OperatorListView operators={filteredAndSortedOperators} onOperatorClick={(op) => navigate(`/operators/${op.slug}`)} />
)
)}
{/* Empty State */}
{!isLoading && filteredAndSortedOperators?.length === 0 && (
<div className="text-center py-12">
<Building className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No park operators found</h3>
<p className="text-muted-foreground">
{searchTerm
? "Try adjusting your search terms or filters"
: "No park operators are currently available"
}
</p>
</div>
)}
</div>
</div>
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<OperatorForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</main>
</div>
);
};
export default Operators;

View File

@@ -0,0 +1,295 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, MapPin, Filter, FerrisWheel } from 'lucide-react';
import { Park, Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { ParkGridView } from '@/components/parks/ParkGridView';
import { ParkListView } from '@/components/parks/ParkListView';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ParkSortOptions } from '@/components/parks/ParkSortOptions';
import { ParkFilters } from '@/components/parks/ParkFilters';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Grid3X3, List } from 'lucide-react';
import { FilterState, SortState } from './Parks';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { handleNonCriticalError } from '@/lib/errorHandler';
const initialFilters: FilterState = {
search: '',
parkType: [],
status: [],
country: [],
minRating: 0,
maxRating: 5,
minRides: 0,
maxRides: 1000,
openingYearStart: null,
openingYearEnd: null,
};
const initialSort: SortState = {
field: 'name',
direction: 'asc'
};
export default function OwnerParks() {
const { ownerSlug } = useParams<{ ownerSlug: string }>();
const navigate = useNavigate();
const [owner, setOwner] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
// Update document title when owner changes
useDocumentTitle(owner ? `${owner.name} - Parks` : 'Owner Parks');
useEffect(() => {
if (ownerSlug) {
fetchData();
}
}, [ownerSlug]);
const fetchData = async () => {
try {
// Fetch owner
const { data: ownerData, error: ownerError} = await supabase
.from('companies')
.select('*')
.eq('slug', ownerSlug || '')
.eq('company_type', 'property_owner')
.maybeSingle();
if (ownerError) throw ownerError;
setOwner(ownerData);
if (ownerData) {
// Fetch parks owned by this property owner
const { data: parksData, error: parksError } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*),
property_owner:companies!parks_property_owner_id_fkey(*)
`)
.eq('property_owner_id', ownerData.id)
.order('name');
if (parksError) throw parksError;
setParks(parksData || []);
}
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Owner Parks Data', metadata: { ownerSlug } });
} finally {
setLoading(false);
}
};
const filteredAndSortedParks = useMemo(() => {
let filtered = parks.filter(park => {
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const matchesSearch =
park.name.toLowerCase().includes(searchTerm) ||
park.description?.toLowerCase().includes(searchTerm) ||
park.location?.city?.toLowerCase().includes(searchTerm) ||
park.location?.country?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
if (filters.parkType.length > 0 && !filters.parkType.includes(park.park_type)) return false;
if (filters.status.length > 0 && !filters.status.includes(park.status)) return false;
if (filters.country.length > 0 && !filters.country.includes(park.location?.country || '')) return false;
const rating = park.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) return false;
const rideCount = park.ride_count || 0;
if (rideCount < filters.minRides || rideCount > filters.maxRides) return false;
return true;
});
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sort.field) {
case 'name':
aValue = a.name;
bValue = b.name;
break;
case 'rating':
aValue = a.average_rating || 0;
bValue = b.average_rating || 0;
break;
case 'rides':
aValue = a.ride_count || 0;
bValue = b.ride_count || 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sort.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return sort.direction === 'asc' ? aValue - bValue : bValue - aValue;
});
return filtered;
}, [parks, filters, sort]);
useOpenGraph({
title: owner ? `Parks by ${owner.name} - ThrillWiki` : 'Owner Parks',
description: owner
? `Explore ${filteredAndSortedParks.length} theme parks owned by ${owner.name}`
: undefined,
imageUrl: owner?.banner_image_url ?? filteredAndSortedParks[0]?.banner_image_url ?? undefined,
imageId: owner?.banner_image_id ?? filteredAndSortedParks[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !!owner && !loading
});
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!owner) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Property Owner Not Found</h1>
<Button onClick={() => navigate('/owners')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Property Owners
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/owners/${ownerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {owner.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Parks Owned by {owner.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all theme parks owned by {owner.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredAndSortedParks.length} parks
</Badge>
</div>
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<ParkSearch
value={filters.search}
onChange={(search) => setFilters(prev => ({ ...prev, search }))}
/>
</div>
<div className="flex gap-2 w-full lg:w-auto">
<div className="flex-1 sm:flex-none">
<ParkSortOptions
sort={sort}
onSortChange={setSort}
/>
</div>
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="shrink-0"
>
<Filter className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">Filters</span>
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{showFilters && (
<Card>
<CardContent className="p-6">
<ParkFilters
filters={filters}
onFiltersChange={setFilters}
parks={parks}
/>
</CardContent>
</Card>
)}
</div>
{filteredAndSortedParks.length > 0 ? (
viewMode === 'grid' ? (
<ParkGridView parks={filteredAndSortedParks} />
) : (
<ParkListView
parks={filteredAndSortedParks}
onParkClick={(park) => navigate(`/parks/${park.slug}`)}
/>
)
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No parks found</h3>
<p className="text-muted-foreground">
{owner.name} doesn't own any parks matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,680 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { MapPin, Star, Clock, Phone, Globe, Calendar, ArrowLeft, Users, Zap, Camera, Castle, FerrisWheel, Waves, Tent, Plus } from 'lucide-react';
import { formatLocationShort } from '@/lib/locationFormatter';
import { useAuth } from '@/hooks/useAuth';
import { ReviewsSection } from '@/components/reviews/ReviewsSection';
import { RideCard } from '@/components/rides/RideCard';
import { Park, Ride } from '@/types/database';
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { toast } from '@/hooks/use-toast';
import { useParkDetail } from '@/hooks/parks/useParkDetail';
import { useParkRides } from '@/hooks/parks/useParkRides';
import { usePhotoCount } from '@/hooks/photos/usePhotoCount';
// Lazy load admin forms
const RideForm = lazy(() => import('@/components/admin/RideForm').then(m => ({ default: m.RideForm })));
const ParkForm = lazy(() => import('@/components/admin/ParkForm').then(m => ({ default: m.ParkForm })));
import { getErrorMessage } from '@/lib/errorHandler';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
import { useUserRole } from '@/hooks/useUserRole';
import { Edit } from 'lucide-react';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { useAuthModal } from '@/hooks/useAuthModal';
import { MetaTags } from '@/components/seo';
export default function ParkDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [isAddRideModalOpen, setIsAddRideModalOpen] = useState(false);
const [isEditParkModalOpen, setIsEditParkModalOpen] = useState(false);
const { isModerator } = useUserRole();
// Fetch park data with caching
const { data: park, isLoading: loading, error } = useParkDetail(slug);
// Fetch rides with caching
const { data: rides = [] } = useParkRides(park?.id, !!park?.id);
// Fetch photo count with caching
const { data: photoCount = 0, isLoading: statsLoading } = usePhotoCount('park', park?.id, !!park?.id);
// Track page view when park is loaded
useEffect(() => {
if (park?.id) {
trackPageView('park', park.id);
}
}, [park?.id]);
const getStatusColor = (status: string) => {
switch (status) {
case 'operating':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'seasonal':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'under_construction':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-red-500/20 text-red-400 border-red-500/30';
}
};
const getParkTypeIcon = (type: string) => {
switch (type) {
case 'theme_park':
return <Castle className="w-20 h-20" />;
case 'amusement_park':
return <FerrisWheel className="w-20 h-20" />;
case 'water_park':
return <Waves className="w-20 h-20" />;
case 'family_entertainment':
return <Tent className="w-20 h-20" />;
default:
return <FerrisWheel className="w-20 h-20" />;
}
};
const formatParkType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
const handleRideSubmit = async (rideData: any) => {
try {
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
await submitRideCreation(
{
...rideData,
park_id: park?.id
},
user!.id
);
toast({
title: "Submission Sent",
description: "Your ride submission has been sent for moderation review.",
});
setIsAddRideModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Submission Failed",
description: errorMsg,
variant: "destructive"
});
}
};
const handleEditParkSubmit = async (parkData: any) => {
if (!user || !park) return;
try {
// Everyone goes through submission queue
const { submitParkUpdate } = await import('@/lib/entitySubmissionHelpers');
await submitParkUpdate(park.id, parkData, user.id);
toast({
title: "Edit Submitted",
description: isModerator()
? "Your edit has been submitted. You can approve it in the moderation queue."
: "Your park edit has been submitted for review.",
});
setIsEditParkModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
if (loading) {
return <div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
</div>;
}
if (!park) {
return <div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Park Not Found</h1>
<p className="text-muted-foreground mb-6">
The park you're looking for doesn't exist or has been removed.
</p>
<Button onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
</div>
</div>
</div>;
}
return <div className="min-h-screen bg-background">
<MetaTags entityType="park" entitySlug={slug} />
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Park
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(park.banner_image_url || park.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={getBannerUrls(park.banner_image_id ?? undefined).mobile ?? park.banner_image_url ?? undefined}
/>
<img
src={getBannerUrls(park.banner_image_id ?? undefined).desktop ?? park.banner_image_url ?? undefined}
alt={park.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : (
<div className="flex items-center justify-center h-full">
<div className="opacity-50">
{getParkTypeIcon(park.park_type)}
</div>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Park Title Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-8">
<div className="flex items-end justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<Badge className={`${getStatusColor(park.status)} border`}>
{park.status.replace('_', ' ').toUpperCase()}
</Badge>
<Badge variant="outline" className="bg-black/20 text-white border-white/20">
{formatParkType(park.park_type)}
</Badge>
</div>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{park.name}
</h1>
{park.location && <div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{formatLocationShort(park.location)}
</div>}
<div className="mt-3">
<VersionIndicator
entityType="park"
entityId={park.id}
entityName={park.name}
/>
</div>
</div>
{(park.average_rating ?? 0) > 0 && <div className="bg-black/20 backdrop-blur-sm rounded-lg p-4 text-center">
<div className="flex items-center gap-2 text-white mb-1">
<Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
<span className="text-2xl font-bold">{(park.average_rating ?? 0).toFixed(1)}</span>
</div>
<div className="text-white/70 text-sm">
{park.review_count} reviews
</div>
</div>}
</div>
</div>
</div>
</div>
{/* Quick Stats */}
<div className="relative mb-12 max-w-6xl mx-auto">
{/* Background decorative elements */}
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-secondary/5 to-accent/5 rounded-3xl blur-xl"></div>
<div className="relative grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gradient-to-br from-background/80 via-card/90 to-background/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-md">
{/* Total Rides */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent hover:shadow-lg hover:shadow-primary/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<FerrisWheel className="w-6 h-6 text-primary" />
</div>
<div className="text-2xl font-bold text-primary mb-1 group-hover:scale-105 transition-transform">
{park.ride_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Total Rides</div>
</CardContent>
</Card>
</div>
{/* Roller Coasters */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent hover:shadow-lg hover:shadow-accent/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<Zap className="w-6 h-6 text-accent" />
</div>
<div className="text-2xl font-bold text-accent mb-1 group-hover:scale-105 transition-transform">
{park.coaster_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Roller Coasters</div>
</CardContent>
</Card>
</div>
{/* Reviews */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-secondary/10 via-secondary/5 to-transparent hover:shadow-lg hover:shadow-secondary/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="absolute top-1 right-1 opacity-20 group-hover:opacity-40 transition-opacity">
<Star className="w-6 h-6 text-secondary" />
</div>
<div className="text-2xl font-bold text-secondary mb-1 group-hover:scale-105 transition-transform">
{park.review_count}
</div>
<div className="text-xs font-medium text-muted-foreground">Reviews</div>
{(park.average_rating ?? 0) > 0 && <div className="flex items-center justify-center gap-1 mt-1">
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
<span className="text-xs font-medium text-yellow-500">
{(park.average_rating ?? 0).toFixed(1)}
</span>
</div>}
</CardContent>
</Card>
</div>
{/* Operating Status */}
<div className="group relative overflow-hidden">
<Card className="h-full border-0 bg-gradient-to-br from-muted/20 via-muted/10 to-transparent hover:shadow-lg hover:shadow-muted/15 transition-all duration-300 hover:scale-[1.02]">
<CardContent className="p-4 text-center relative">
<div className="flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<div className="p-2 rounded-full bg-gradient-to-br from-primary/20 to-accent/20">
{park.opening_date ? <Calendar className="w-5 h-5" /> : <Clock className="w-5 h-5" />}
</div>
</div>
<div className="text-xs font-medium text-foreground">
{park.opening_date ? `Opened ${park.opening_date.split('-')[0]}` : 'Opening Soon'}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Main Content */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">
Rides {rides.length > 0 && `(${rides.length})`}
</TabsTrigger>
<TabsTrigger value="reviews">
Reviews {(park.review_count ?? 0) > 0 && `(${park.review_count})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && photoCount > 0 && `(${photoCount})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-6">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Description */}
{park.description && <Card>
<CardHeader>
<CardTitle>About {park.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">
{park.description}
</p>
</CardContent>
</Card>}
{/* Featured Rides */}
{rides.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Featured Rides</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 lg:gap-5 xl:gap-4">
{rides.slice(0, 4).map(ride => (
<RideCard
key={ride.id}
ride={ride}
showParkName={false}
parkSlug={park.slug}
className="h-full"
/>
))}
</div>
{rides.length > 4 && (
<div className="mt-4 text-center">
<Button
variant="outline"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
<div className="space-y-6">
{/* Park Information */}
<Card>
<CardHeader>
<CardTitle>Park Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{park.opening_date && <div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Opened</div>
<div className="text-sm text-muted-foreground">
{new Date(park.opening_date).getFullYear()}
</div>
</div>
</div>}
{park.operator && <div className="flex items-center gap-3">
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Operator</div>
<div className="text-sm text-muted-foreground">
{park.operator.name}
</div>
</div>
</div>}
{park.website_url && <div className="flex items-center gap-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Website</div>
<a href={park.website_url} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline">
Visit Website
</a>
</div>
</div>}
{park.phone && <div className="flex items-center gap-3">
<Phone className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Phone</div>
<div className="text-sm text-muted-foreground">
{park.phone}
</div>
</div>
</div>}
<Separator />
<div className="space-y-4">
<div className="font-medium">Location</div>
{park.location && (
<div className="space-y-3">
{/* Full Address Display */}
<div className="text-sm text-muted-foreground">
<div className="font-medium text-foreground mb-1">Address:</div>
<div className="space-y-1">
{/* Street Address on its own line if it exists */}
{park.location.street_address && (
<div>{park.location.street_address}</div>
)}
{/* City, State Postal on same line */}
{(park.location.city || park.location.state_province || park.location.postal_code) && (
<div>
{park.location.city}
{park.location.city && park.location.state_province && ', '}
{park.location.state_province}
{park.location.postal_code && ` ${park.location.postal_code}`}
</div>
)}
{/* Country on its own line */}
{park.location.country && (
<div>{park.location.country}</div>
)}
</div>
</div>
{/* Map Links */}
{park.location?.latitude && park.location?.longitude && (
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
asChild
className="text-xs"
>
<a
href={`https://maps.apple.com/?q=${encodeURIComponent(park.name)}&ll=${park.location.latitude},${park.location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<MapPin className="w-3 h-3" />
Apple Maps
</a>
</Button>
<Button
variant="outline"
size="sm"
asChild
className="text-xs"
>
<a
href={`https://maps.google.com/?q=${encodeURIComponent(park.name)}&ll=${park.location.latitude},${park.location.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<Globe className="w-3 h-3" />
Google Maps
</a>
</Button>
</div>
)}
</div>
)}
{park.location?.latitude && park.location?.longitude && (
<div className="mt-4">
<ParkLocationMap
latitude={Number(park.location.latitude)}
longitude={Number(park.location.longitude)}
parkName={park.name}
/>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="rides" className="mt-6">
{/* Header with Add Ride button */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Rides at {park.name}</h2>
<Button onClick={() => requireAuth(() => setIsAddRideModalOpen(true), "Sign in to add a ride")}>
<Plus className="w-4 h-4 mr-2" />
Add Ride
</Button>
</div>
{/* Conditional rendering */}
{rides.length === 0 ? (
<Card className="border-dashed bg-muted/50">
<CardContent className="p-12 text-center">
<FerrisWheel className="w-16 h-16 mx-auto mb-4 text-muted-foreground/40" />
<h3 className="text-xl font-semibold mb-2">No rides yet</h3>
<p className="text-muted-foreground">
Be the first to add a ride to this park
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{rides.map(ride => (
<RideCard
key={ride.id}
ride={ride}
showParkName={false}
parkSlug={park.slug}
/>
))}
</div>
<div className="mt-8 text-center">
<Button
variant="outline"
size="lg"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
</>
)}
</TabsContent>
<TabsContent value="reviews" className="mt-6">
<ReviewsSection entityType="park" entityId={park.id} entityName={park.name} averageRating={park.average_rating ?? 0} reviewCount={park.review_count ?? 0} />
</TabsContent>
<TabsContent value="photos" className="mt-6">
<EntityPhotoGallery
entityId={park.id}
entityType="park"
entityName={park.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="park"
entityId={park.id}
entityName={park.name}
/>
</TabsContent>
</Tabs>
{/* Add Ride Modal */}
<Dialog open={isAddRideModalOpen} onOpenChange={setIsAddRideModalOpen}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Ride to {park.name}</DialogTitle>
<DialogDescription>
Submit a new ride for moderation. All submissions are reviewed before being published.
</DialogDescription>
</DialogHeader>
<Suspense fallback={<AdminFormSkeleton />}>
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleRideSubmit}
onCancel={() => setIsAddRideModalOpen(false)}
initialData={{ park_id: park.id }}
/>
</SubmissionErrorBoundary>
</Suspense>
</DialogContent>
</Dialog>
{/* Edit Park Modal */}
<Dialog open={isEditParkModalOpen} onOpenChange={setIsEditParkModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Park</DialogTitle>
<DialogDescription>
Make changes to the park information. {isModerator() ? 'Changes will be applied immediately.' : 'Your changes will be submitted for review.'}
</DialogDescription>
</DialogHeader>
<Suspense fallback={<AdminFormSkeleton />}>
<SubmissionErrorBoundary>
<ParkForm
onSubmit={handleEditParkSubmit}
onCancel={() => setIsEditParkModalOpen(false)}
initialData={{
id: park?.id,
name: park?.name,
slug: park?.slug,
description: park?.description ?? undefined,
park_type: park?.park_type,
status: park?.status,
opening_date: park?.opening_date ?? undefined,
opening_date_precision: (park?.opening_date_precision as 'day' | 'month' | 'year') ?? undefined,
closing_date: park?.closing_date ?? undefined,
closing_date_precision: (park?.closing_date_precision as 'day' | 'month' | 'year') ?? undefined,
location_id: park?.location?.id,
location: park?.location ? {
name: park.location.name || '',
city: park.location.city || '',
state_province: park.location.state_province || '',
country: park.location.country || '',
postal_code: park.location.postal_code || '',
latitude: park.location.latitude || 0,
longitude: park.location.longitude || 0,
timezone: park.location.timezone || '',
display_name: park.location.name || '',
} : undefined,
website_url: park?.website_url ?? undefined,
phone: park?.phone ?? undefined,
email: park?.email ?? undefined,
operator_id: park?.operator?.id,
property_owner_id: park?.property_owner?.id,
banner_image_url: park?.banner_image_url ?? undefined,
card_image_url: park?.card_image_url ?? undefined
}}
isEditing={true}
/>
</SubmissionErrorBoundary>
</Suspense>
</DialogContent>
</Dialog>
</main>
</div>;
}

View File

@@ -0,0 +1,259 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, Building2, Plus } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import ParkOwnerCard from '@/components/park-owners/ParkOwnerCard';
import { PropertyOwnerForm } from '@/components/admin/PropertyOwnerForm';
import { Company } from '@/types/database';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { submitCompanyCreation } from '@/lib/companyHelpers';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
const ParkOwners = () => {
useDocumentTitle('Property Owners');
const navigate = useNavigate();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterBy, setFilterBy] = useState('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data: parkOwners, isLoading } = useQuery({
queryKey: ['park-owners'],
queryFn: async () => {
// Get companies that are property owners with park counts
const { data, error } = await supabase
.from('companies')
.select(`
*,
parks:parks!property_owner_id(count)
`)
.in('id',
await supabase
.from('parks')
.select('property_owner_id')
.not('property_owner_id', 'is', null)
.then(({ data }) => data?.map(park => park.property_owner_id).filter((id): id is string => id !== null) || [])
)
.order('name');
if (error) throw error;
// Transform the data to include park_count
const transformedData = data?.map(company => ({
...company,
park_count: company.parks?.[0]?.count || 0
})) || [];
return transformedData as (Company & { park_count: number })[];
},
});
const handleCreateSubmit = async (data: any) => {
try {
await submitCompanyCreation(
data,
'property_owner',
user!.id
);
toast({
title: "Property Owner Submitted",
description: "Your submission has been sent for review."
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg,
variant: "destructive"
});
}
};
const filteredAndSortedOwners = React.useMemo(() => {
if (!parkOwners) return [];
let filtered = parkOwners.filter(owner => {
const matchesSearch = owner.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(owner.description && owner.description.toLowerCase().includes(searchTerm.toLowerCase()));
if (filterBy === 'all') return matchesSearch;
if (filterBy === 'with-rating') return matchesSearch && (owner.average_rating ?? 0) > 0;
if (filterBy === 'established') return matchesSearch && owner.founded_year;
return matchesSearch;
});
// Sort
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'rating':
return (b.average_rating || 0) - (a.average_rating || 0);
case 'founded':
return (b.founded_year || 0) - (a.founded_year || 0);
case 'reviews':
return (b.review_count || 0) - (a.review_count || 0);
default:
return 0;
}
});
return filtered;
}, [parkOwners, searchTerm, sortBy, filterBy]);
useOpenGraph({
title: 'Property Owners - ThrillWiki',
description: `Browse ${filteredAndSortedOwners.length} theme park property owners worldwide`,
imageUrl: filteredAndSortedOwners[0]?.banner_image_url ?? undefined,
imageId: filteredAndSortedOwners[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !isLoading
});
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<Building2 className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Property Owners</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Discover companies that own and manage theme parks
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredAndSortedOwners?.length || 0} owners
</Badge>
{searchTerm && (
<Badge variant="outline" className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap">
Searching: "{searchTerm}"
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a property owner")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Property Owner
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="space-y-3 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search property owners..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11"
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="flex-1 h-10">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
<SelectItem value="founded">Founded Year</SelectItem>
<SelectItem value="reviews">Review Count</SelectItem>
</SelectContent>
</Select>
<Select value={filterBy} onValueChange={setFilterBy}>
<SelectTrigger className="flex-1 h-10">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="with-rating">Rated</SelectItem>
<SelectItem value="established">Est.</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-4">Loading property owners...</p>
</div>
)}
{/* Property Owners Grid */}
{!isLoading && filteredAndSortedOwners && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredAndSortedOwners.map((owner) => (
<ParkOwnerCard key={owner.id} company={owner} />
))}
</div>
)}
{/* Empty State */}
{!isLoading && filteredAndSortedOwners?.length === 0 && (
<div className="text-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No property owners found</h3>
<p className="text-muted-foreground">
{searchTerm
? "Try adjusting your search terms or filters"
: "No property owners are currently available"
}
</p>
</div>
)}
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<PropertyOwnerForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</main>
</div>
);
};
export default ParkOwners;

370
src-old/pages/ParkRides.tsx Normal file
View File

@@ -0,0 +1,370 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, SlidersHorizontal, FerrisWheel, Plus, ArrowLeft } from 'lucide-react';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { RideCard } from '@/components/rides/RideCard';
import { RideForm } from '@/components/admin/RideForm';
import { Ride, Park } from '@/types/database';
import { RideSubmissionData } from '@/types/submission-data';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function ParkRides() {
const { parkSlug } = useParams<{ parkSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [park, setPark] = useState<Park | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
// Update document title when park changes
useDocumentTitle(park ? `${park.name} - Rides` : 'Park Rides');
const [filterStatus, setFilterStatus] = useState('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
useEffect(() => {
if (parkSlug) {
fetchParkAndRides();
}
}, [parkSlug, sortBy, filterCategory, filterStatus]);
const fetchParkAndRides = async () => {
try {
setLoading(true);
// Fetch park details first
const { data: parkData, error: parkError } = await supabase
.from('parks')
.select('*')
.eq('slug', parkSlug || '')
.maybeSingle();
if (parkError) throw parkError;
if (!parkData) {
toast({
title: "Park Not Found",
description: "The park you're looking for doesn't exist.",
variant: "destructive"
});
navigate('/parks');
return;
}
setPark(parkData);
// Fetch rides for this park
let query = supabase
.from('rides')
.select(`
*,
manufacturer:companies!rides_manufacturer_id_fkey(*)
`)
.eq('park_id', parkData.id);
// Apply filters
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
// Apply sorting
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('max_height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
// Supabase returns nullable types, but our Ride type uses undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setRides((ridesData || []) as any);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Park Rides', metadata: { parkSlug } });
toast({
title: "Error",
description: "Failed to load park rides.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleCreateSubmit = async (data: Omit<RideSubmissionData, 'park_id'> & { park_id?: string }) => {
try {
if (!park) {
toast({
title: "Error",
description: "Park information not loaded",
variant: "destructive"
});
return;
}
// Pre-fill park_id in the submission and convert nulls to undefined
const submissionData = {
...data,
description: data.description ?? undefined,
park_id: park.id,
};
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
// Type assertion needed: Form data structure doesn't perfectly match submission helper expectations
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await submitRideCreation(submissionData as any, user!.id);
toast({
title: "Ride Submitted",
description: "Your ride submission has been sent for moderation review.",
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Submission Failed",
description: errorMsg,
variant: "destructive"
});
}
};
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.manufacturer?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
useOpenGraph({
title: park ? `${park.name} - Rides & Attractions` : 'Park Rides',
description: park
? `Explore ${filteredRides.length} rides and attractions at ${park.name}`
: undefined,
imageUrl: park?.banner_image_url ?? filteredRides[0]?.banner_image_url ?? undefined,
imageId: park?.banner_image_id ?? filteredRides[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !!park && !loading
});
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!park) {
return null;
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Back Button & Page Header */}
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(`/parks/${park.slug}`)}
className="mb-4 -ml-2 gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back to {park.name}
</Button>
<div className="flex items-center gap-3 mb-4">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">{park.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Rides & Attractions
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredRides.length} rides
</Badge>
<Badge variant="outline" className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a new ride")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Ride
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or manufacturer..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Rides Grid */}
{filteredRides.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredRides.map((ride) => (
<RideCard
key={ride.id}
ride={ride}
showParkName={false}
parkSlug={park.slug}
/>
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground mb-4">
{rides.length === 0
? `${park.name} doesn't have any rides yet.`
: 'Try adjusting your search criteria or filters'
}
</p>
{rides.length === 0 && user && (
<Button onClick={() => setIsCreateModalOpen(true)} className="gap-2">
<Plus className="w-4 h-4" />
Add First Ride
</Button>
)}
</div>
)}
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleCreateSubmit as any}
onCancel={() => setIsCreateModalOpen(false)}
isEditing={false}
initialData={{ park_id: park.id }}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</main>
</div>
);
}

595
src-old/pages/Parks.tsx Normal file
View File

@@ -0,0 +1,595 @@
import { useState, useEffect, useMemo } from "react";
import { Header } from "@/components/layout/Header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MapPin,
Grid3X3,
List,
Map,
Filter,
SortAsc,
Search,
ChevronDown,
Sliders,
X,
FerrisWheel,
Plus,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { Park } from "@/types/database";
import { supabase } from "@/lib/supabaseClient";
import { useNavigate } from "react-router-dom";
import { ParkFilters } from "@/components/parks/ParkFilters";
import { ParkGridView } from "@/components/parks/ParkGridView";
import { ParkListView } from "@/components/parks/ParkListView";
import { ParkSearch } from "@/components/parks/ParkSearch";
import { ParkSortOptions } from "@/components/parks/ParkSortOptions";
import { useToast } from "@/hooks/use-toast";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ParkForm } from "@/components/admin/ParkForm";
import { useAuth } from "@/hooks/useAuth";
import { useUserRole } from "@/hooks/useUserRole";
import { useAuthModal } from "@/hooks/useAuthModal";
import { useOpenGraph } from "@/hooks/useOpenGraph";
import { useParks } from "@/hooks/parks/useParks";
import { Pagination } from "@/components/common/Pagination";
import { SubmissionErrorBoundary } from "@/components/error/SubmissionErrorBoundary";
export interface FilterState {
search: string;
parkType: string[];
status: string[];
country: string[];
states?: string[];
cities?: string[];
operators?: string[];
propertyOwners?: string[];
minRating: number;
maxRating: number;
minRides: number;
maxRides: number;
minCoasters?: number;
maxCoasters?: number;
minReviews?: number;
maxReviews?: number;
openingYearStart: number | null;
openingYearEnd: number | null;
openingDateFrom?: string | null;
openingDateTo?: string | null;
}
export interface SortState {
field: string;
direction: "asc" | "desc";
}
const initialFilters: FilterState = {
search: "",
parkType: [],
status: [],
country: [],
states: [],
cities: [],
operators: [],
propertyOwners: [],
minRating: 0,
maxRating: 5,
minRides: 0,
maxRides: 1000,
minCoasters: 0,
maxCoasters: 100,
minReviews: 0,
maxReviews: 1000,
openingYearStart: null,
openingYearEnd: null,
openingDateFrom: null,
openingDateTo: null,
};
const initialSort: SortState = {
field: "name",
direction: "asc",
};
export default function Parks() {
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [showFilters, setShowFilters] = useState(false);
const [isAddParkModalOpen, setIsAddParkModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem("parks-sidebar-collapsed");
return saved ? JSON.parse(saved) : false;
});
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
// Use TanStack Query hook for data fetching with caching
const { data: parks = [], isLoading: loading, error } = useParks();
useEffect(() => {
localStorage.setItem("parks-sidebar-collapsed", JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
// Show error toast if query fails
useEffect(() => {
if (error) {
toast({
variant: "destructive",
title: "Error loading parks",
description: error instanceof Error ? error.message : "Failed to load parks",
});
}
}, [error, toast]);
const filteredAndSortedParks = useMemo(() => {
let filtered = parks.filter((park) => {
// Search filter
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const matchesSearch =
park.name.toLowerCase().includes(searchTerm) ||
park.description?.toLowerCase().includes(searchTerm) ||
park.location?.city?.toLowerCase().includes(searchTerm) ||
park.location?.country?.toLowerCase().includes(searchTerm) ||
park.location?.state_province?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
// Park type filter
if (filters.parkType.length > 0 && !filters.parkType.includes(park.park_type)) {
return false;
}
// Status filter
if (filters.status.length > 0 && !filters.status.includes(park.status)) {
return false;
}
// Country filter
if (filters.country.length > 0 && !filters.country.includes(park.location?.country || "")) {
return false;
}
// States filter
if (filters.states && filters.states.length > 0) {
if (!park.location?.state_province || !filters.states.includes(park.location.state_province)) {
return false;
}
}
// Cities filter
if (filters.cities && filters.cities.length > 0) {
if (!park.location?.city || !filters.cities.includes(park.location.city)) {
return false;
}
}
// Operators filter
if (filters.operators && filters.operators.length > 0) {
if (!park.operator?.id || !filters.operators.includes(park.operator.id)) {
return false;
}
}
// Property owners filter
if (filters.propertyOwners && filters.propertyOwners.length > 0) {
if (!park.property_owner?.id || !filters.propertyOwners.includes(park.property_owner.id)) {
return false;
}
}
// Rating filter
const rating = park.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) {
return false;
}
// Ride count filter
const rideCount = park.ride_count || 0;
if (rideCount < filters.minRides || rideCount > filters.maxRides) {
return false;
}
// Coaster count filter
if (filters.minCoasters !== undefined && filters.maxCoasters !== undefined) {
const coasterCount = park.coaster_count || 0;
if (coasterCount < filters.minCoasters || coasterCount > filters.maxCoasters) {
return false;
}
}
// Review count filter
if (filters.minReviews !== undefined && filters.maxReviews !== undefined) {
const reviewCount = park.review_count || 0;
if (reviewCount < filters.minReviews || reviewCount > filters.maxReviews) {
return false;
}
}
// Opening date filter (timezone-independent)
if (filters.openingDateFrom || filters.openingDateTo || filters.openingYearStart || filters.openingYearEnd) {
if (!park.opening_date) {
return false;
}
// Full date filtering (if date range is set)
if (filters.openingDateFrom && park.opening_date < filters.openingDateFrom) {
return false;
}
if (filters.openingDateTo && park.opening_date > filters.openingDateTo) {
return false;
}
// Year-only filtering (for backward compatibility)
const openingYear = parseInt(park.opening_date.split("-")[0]);
if (filters.openingYearStart && openingYear < filters.openingYearStart) {
return false;
}
if (filters.openingYearEnd && openingYear > filters.openingYearEnd) {
return false;
}
}
return true;
});
// Apply sorting
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sort.field) {
case "name":
aValue = a.name;
bValue = b.name;
break;
case "rating":
aValue = a.average_rating || 0;
bValue = b.average_rating || 0;
break;
case "rides":
aValue = a.ride_count || 0;
bValue = b.ride_count || 0;
break;
case "coasters":
aValue = a.coaster_count || 0;
bValue = b.coaster_count || 0;
break;
case "reviews":
aValue = a.review_count || 0;
bValue = b.review_count || 0;
break;
case "opening":
aValue = a.opening_date ? new Date(a.opening_date).getTime() : 0;
bValue = b.opening_date ? new Date(b.opening_date).getTime() : 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === "string" && typeof bValue === "string") {
const result = aValue.localeCompare(bValue);
return sort.direction === "asc" ? result : -result;
}
const result = aValue - bValue;
return sort.direction === "asc" ? result : -result;
});
return filtered;
}, [parks, filters, sort]);
const generateDescription = () => {
if (!parks.length) return "Browse theme parks worldwide on ThrillWiki";
const activeFilters: string[] = [];
if (filters.country.length === 1) activeFilters.push(`in ${filters.country[0]!}`);
if (filters.parkType.length > 0) activeFilters.push(...filters.parkType);
if (filters.status.length > 0) activeFilters.push(...filters.status);
if (activeFilters.length > 0) {
return `Browse ${filteredAndSortedParks.length} ${activeFilters.join(" ")} theme parks`;
}
return `Browse ${parks.length} theme parks worldwide`;
};
useOpenGraph({
title: "Parks - ThrillWiki",
description: generateDescription(),
imageUrl: filteredAndSortedParks[0]?.banner_image_url ?? undefined,
imageId: filteredAndSortedParks[0]?.banner_image_id ?? undefined,
type: "website",
});
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.search) count++;
if (filters.parkType.length > 0) count++;
if (filters.status.length > 0) count++;
if (filters.country.length > 0) count++;
if (filters.minRating > 0 || filters.maxRating < 5) count++;
if (filters.minRides > 0 || filters.maxRides < 1000) count++;
if (filters.openingYearStart || filters.openingYearEnd) count++;
return count;
}, [filters]);
const clearAllFilters = () => {
setFilters(initialFilters);
setSort(initialSort);
};
const handleParkClick = (park: Park) => {
navigate(`/parks/${park.slug}`);
};
// Pagination for display
const ITEMS_PER_PAGE = 24;
const paginatedParks = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
return filteredAndSortedParks.slice(start, end);
}, [filteredAndSortedParks, currentPage]);
const totalPages = Math.ceil(filteredAndSortedParks.length / ITEMS_PER_PAGE);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [filters, sort]);
const handleParkSubmit = async (parkData: any) => {
try {
const { submitParkCreation } = await import("@/lib/entitySubmissionHelpers");
await submitParkCreation(parkData, user!.id);
toast({
title: "Park Submitted",
description: "Your park submission has been sent for moderation review.",
});
setIsAddParkModalOpen(false);
} catch (error) {
toast({
title: "Submission Failed",
description: error instanceof Error ? error.message : "Failed to submit park.",
variant: "destructive",
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="flex gap-4">
<div className="h-10 bg-muted rounded flex-1"></div>
<div className="h-10 bg-muted rounded w-32"></div>
<div className="h-10 bg-muted rounded w-32"></div>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Parks</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Discover amazing theme parks, amusement parks, and attractions worldwide
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge
variant="secondary"
className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap"
>
{filteredAndSortedParks.length} parks
</Badge>
<Badge
variant="outline"
className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap"
>
{parks.reduce((sum, park) => sum + (park.ride_count || 0), 0)} total rides
</Badge>
<Badge
variant="outline"
className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap hidden xs:inline-flex sm:inline-flex"
>
{parks.reduce((sum, park) => sum + (park.coaster_count || 0), 0)} coasters
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsAddParkModalOpen(true), "Sign in to add a new park")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Park
</Button>
{activeFilterCount > 0 && (
<Button variant="outline" onClick={clearAllFilters} className="text-destructive hover:text-destructive">
<X className="w-4 h-4 mr-2" />
Clear all filters
</Button>
)}
</div>
</div>
</div>
{/* Search and Controls */}
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Desktop: Filter toggle on the left */}
<Button
variant="outline"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="shrink-0 gap-2 hidden lg:flex"
title={sidebarCollapsed ? "Show filters" : "Hide filters"}
>
{sidebarCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
<span>Filters</span>
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilterCount}
</Badge>
)}
</Button>
{/* Search bar takes remaining space */}
<div className="flex-1">
<ParkSearch value={filters.search} onChange={(search) => setFilters((prev) => ({ ...prev, search }))} />
</div>
{/* Sort controls - more compact */}
<div className="flex gap-2">
<ParkSortOptions sort={sort} onSortChange={setSort} />
{/* Mobile filter toggle */}
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="shrink-0 gap-2 lg:hidden"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? "rotate-180" : ""}`} />
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilterCount}
</Badge>
)}
</Button>
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as "grid" | "list")}
className="hidden md:inline-flex"
>
<TabsList>
<TabsTrigger value="grid">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Mobile Filters */}
<div className="lg:hidden">
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent>
<Card>
<CardContent className="pt-6">
<ParkFilters filters={filters} onFiltersChange={setFilters} parks={parks} />
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Main Content Area with Sidebar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop Filter Sidebar */}
{!sidebarCollapsed && (
<aside className="hidden lg:block flex-shrink-0 transition-all duration-300 lg:w-[340px] xl:w-[380px] 2xl:w-[420px]">
<div className="sticky top-24">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Filters</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setSidebarCollapsed(true)} title="Hide filters">
<PanelLeftClose className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<ParkFilters filters={filters} onFiltersChange={setFilters} parks={parks} />
</CardContent>
</Card>
</div>
</aside>
)}
{/* Results Area */}
<div className="flex-1 min-w-0">
{filteredAndSortedParks.length > 0 ? (
<div>
{viewMode === "grid" ? (
<ParkGridView parks={paginatedParks} />
) : (
<ParkListView parks={paginatedParks} onParkClick={handleParkClick} />
)}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
isLoading={loading}
/>
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No parks found</h3>
<p className="text-muted-foreground mb-4">Try adjusting your search terms or filters</p>
<Button onClick={clearAllFilters} variant="outline">
Clear all filters
</Button>
</div>
)}
</div>
</div>
{/* Add Park Modal */}
<Dialog open={isAddParkModalOpen} onOpenChange={setIsAddParkModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Park</DialogTitle>
<DialogDescription>
Add a new park to the database. Your submission will be reviewed before being published.
</DialogDescription>
</DialogHeader>
<SubmissionErrorBoundary>
<ParkForm onSubmit={handleParkSubmit} onCancel={() => setIsAddParkModalOpen(false)} isEditing={false} />
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</main>
</div>
);
}

88
src-old/pages/Privacy.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { Header } from '@/components/layout/Header';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function Privacy() {
useDocumentTitle('Privacy Policy');
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="prose dark:prose-invert max-w-none">
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Information We Collect</h2>
<p className="text-muted-foreground mb-4">
We collect information you provide directly to us, such as when you create an account, submit reviews, or contact us.
</p>
<ul className="list-disc pl-6 text-muted-foreground mb-4">
<li>Account information (email, username, display name)</li>
<li>Profile information and photos</li>
<li>Reviews, ratings, and comments</li>
<li>Photos and media uploads</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">How We Use Your Information</h2>
<p className="text-muted-foreground mb-4">
We use the information we collect to:
</p>
<ul className="list-disc pl-6 text-muted-foreground mb-4">
<li>Provide and maintain ThrillWiki services</li>
<li>Process and display your reviews and content</li>
<li>Communicate with you about your account</li>
<li>Improve our services and user experience</li>
<li>Prevent fraud and maintain security</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Information Sharing</h2>
<p className="text-muted-foreground mb-4">
We do not sell, trade, or share your personal information with third parties except as described in this policy:
</p>
<ul className="list-disc pl-6 text-muted-foreground mb-4">
<li>Public content (reviews, ratings) is visible to all users</li>
<li>We may share information if required by law</li>
<li>We may share aggregated, non-personal data for research</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Data Security</h2>
<p className="text-muted-foreground mb-4">
We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
<p className="text-muted-foreground mb-4">
You have the right to:
</p>
<ul className="list-disc pl-6 text-muted-foreground mb-4">
<li>Access and update your account information</li>
<li>Delete your account and associated data</li>
<li>Export your data</li>
<li>Opt out of communications</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Cookies and Analytics</h2>
<p className="text-muted-foreground mb-4">
We use cookies and similar technologies to improve your experience and analyze usage patterns. You can control cookie settings in your browser.
</p>
</section>
<div className="mt-12 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
Last updated: January 2024
</p>
</div>
</div>
</main>
</div>
);
}

1125
src-old/pages/Profile.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { ArrowLeft, MapPin, Star, Globe, Calendar, Edit, Building2, Gauge } from 'lucide-react';
import { Company, Park } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { PropertyOwnerPhotoGallery } from '@/components/companies/PropertyOwnerPhotoGallery';
import { ParkCard } from '@/components/parks/ParkCard';
import { handleNonCriticalError } from '@/lib/errorHandler';
// Lazy load admin form
const PropertyOwnerForm = lazy(() => import('@/components/admin/PropertyOwnerForm').then(m => ({ default: m.PropertyOwnerForm })));
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { submitCompanyUpdate } from '@/lib/companyHelpers';
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function PropertyOwnerDetail() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [owner, setOwner] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [totalParks, setTotalParks] = useState<number>(0);
const [operatingRides, setOperatingRides] = useState<number>(0);
const [statsLoading, setStatsLoading] = useState(true);
const [totalPhotos, setTotalPhotos] = useState<number>(0);
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
// Update document title when owner changes
useDocumentTitle(owner?.name || 'Property Owner Details');
// Update Open Graph meta tags
useOpenGraph({
title: owner?.name || '',
description: owner?.description || (owner ? `${owner.name} - Property Owner${owner.headquarters_location ? ` based in ${owner.headquarters_location}` : ''}` : ''),
imageUrl: owner?.banner_image_url ?? undefined,
imageId: owner?.banner_image_id ?? undefined,
type: 'profile',
enabled: !!owner
});
useEffect(() => {
if (slug) {
fetchOwnerData();
}
}, [slug]);
// Track page view when property owner is loaded
useEffect(() => {
if (owner?.id) {
trackPageView('company', owner.id);
}
}, [owner?.id]);
const fetchOwnerData = async () => {
try {
const { data, error } = await supabase
.from('companies')
.select('*')
.eq('slug', slug || '')
.eq('company_type', 'property_owner')
.maybeSingle();
if (error) throw error;
setOwner(data);
// Fetch parks owned by this property owner
if (data) {
fetchParks(data.id);
fetchStatistics(data.id);
fetchPhotoCount(data.id);
}
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Property Owner', metadata: { slug } });
} finally {
setLoading(false);
}
};
const fetchParks = async (ownerId: string) => {
try {
const { data, error } = await supabase
.from('parks')
.select(`
*,
location:locations(*)
`)
.eq('property_owner_id', ownerId)
.order('name')
.limit(6);
if (error) throw error;
setParks(data || []);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Owner Parks', metadata: { ownerId } });
} finally {
setParksLoading(false);
}
};
const fetchStatistics = async (ownerId: string) => {
try {
// Get total parks count
const { count: parksCount, error: parksError } = await supabase
.from('parks')
.select('id', { count: 'exact', head: true })
.eq('property_owner_id', ownerId);
if (parksError) throw parksError;
setTotalParks(parksCount || 0);
// Get operating rides count across all owned parks
const { data: ridesData, error: ridesError } = await supabase
.from('rides')
.select('id, parks!inner(property_owner_id)')
.eq('parks.property_owner_id', ownerId)
.eq('status', 'operating');
if (ridesError) throw ridesError;
setOperatingRides(ridesData?.length || 0);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Owner Statistics', metadata: { ownerId } });
} finally {
setStatsLoading(false);
}
};
const fetchPhotoCount = async (ownerId: string) => {
try {
const { count, error } = await supabase
.from('photos')
.select('id', { count: 'exact', head: true })
.eq('entity_type', 'property_owner')
.eq('entity_id', ownerId);
if (error) throw error;
setTotalPhotos(count || 0);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Owner Photo Count', metadata: { ownerId } });
setTotalPhotos(0);
}
};
const handleEditSubmit = async (data: any) => {
try {
await submitCompanyUpdate(
owner!.id,
data,
user!.id
);
toast({
title: "Edit Submitted",
description: "Your edit has been submitted for review."
});
setIsEditModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg || "Failed to submit edit.",
variant: "destructive"
});
}
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
</div>
);
}
if (!owner) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Property Owner Not Found</h1>
<Button onClick={() => navigate('/owners')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Property Owners
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/owners')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Property Owners
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this property owner")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Property Owner
</Button>
</div>
{/* Hero Section */}
<div className="relative mb-8">
<div className="aspect-[21/9] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 rounded-lg overflow-hidden relative">
{(owner.banner_image_url || owner.banner_image_id) ? (
<picture>
<source
media="(max-width: 768px)"
srcSet={getBannerUrls(owner.banner_image_id ?? undefined).mobile || owner.banner_image_url || undefined}
/>
<img
src={getBannerUrls(owner.banner_image_id ?? undefined).desktop || owner.banner_image_url || undefined}
alt={owner.name}
className="w-full h-full object-cover"
loading="eager"
/>
</picture>
) : owner.logo_url ? (
<div className="flex items-center justify-center h-full bg-background/90">
<img
src={owner.logo_url}
alt={owner.name}
className="max-h-48 object-contain"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<Building2 className="w-24 h-24 opacity-50" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-end justify-between">
<div>
<Badge variant="outline" className="bg-black/20 text-white border-white/20 mb-2">
Property Owner
</Badge>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{owner.name}
</h1>
{owner.headquarters_location && (
<div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{owner.headquarters_location}
</div>
)}
<div className="mt-3">
<VersionIndicator
entityType="company"
entityId={owner.id}
entityName={owner.name}
/>
</div>
</div>
{(owner.average_rating ?? 0) > 0 && (
<div className="bg-black/30 backdrop-blur-md rounded-lg p-6 text-center">
<div className="flex items-center gap-2 text-white mb-2">
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="text-3xl font-bold">
{(owner.average_rating ?? 0).toFixed(1)}
</span>
</div>
<div className="text-white/90 text-sm">
{owner.review_count} {owner.review_count === 1 ? "review" : "reviews"}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Company Info */}
<div className="flex flex-wrap justify-center gap-4 mb-8">
{!statsLoading && totalParks > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Building2 className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{totalParks}</div>
<div className="text-sm text-muted-foreground">
{totalParks === 1 ? 'Park Owned' : 'Parks Owned'}
</div>
</CardContent>
</Card>
)}
{!statsLoading && operatingRides > 0 && (
<Card>
<CardContent className="p-4 text-center">
<Gauge className="w-6 h-6 text-accent mx-auto mb-2" />
<div className="text-2xl font-bold">{operatingRides}</div>
<div className="text-sm text-muted-foreground">
Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
</div>
</CardContent>
</Card>
)}
{owner.founded_year && (
<Card>
<CardContent className="p-4 text-center">
<Calendar className="w-6 h-6 text-primary mx-auto mb-2" />
<div className="text-2xl font-bold">{owner.founded_year}</div>
<div className="text-sm text-muted-foreground">Founded</div>
</CardContent>
</Card>
)}
{owner.website_url && (
<Card>
<CardContent className="p-4 text-center">
<Globe className="w-6 h-6 text-primary mx-auto mb-2" />
<a
href={owner.website_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline break-all"
>
Visit Website
</a>
</CardContent>
</Card>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="parks">
Parks {!statsLoading && totalParks > 0 && `(${totalParks})`}
</TabsTrigger>
<TabsTrigger value="photos">
Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`}
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{owner.description && (
<Card>
<CardContent className="p-6">
<h2 className="text-2xl font-bold mb-4">About</h2>
<p className="text-muted-foreground whitespace-pre-wrap">
{owner.description}
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="parks">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Parks Owned</h2>
<Button
variant="outline"
onClick={() => navigate(`/owners/${owner.slug}/parks`)}
>
View All Parks
</Button>
</div>
{parksLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
</div>
) : parks.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No parks found for {owner.name}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="photos">
<PropertyOwnerPhotoGallery
propertyOwnerId={owner.id}
propertyOwnerName={owner.name}
/>
</TabsContent>
<TabsContent value="history" className="mt-6">
<EntityHistoryTabs
entityType="company"
entityId={owner.id}
entityName={owner.name}
/>
</TabsContent>
</Tabs>
</main>
{/* Edit Modal */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<Suspense fallback={<AdminFormSkeleton />}>
<PropertyOwnerForm
initialData={{
id: owner.id,
name: owner.name,
slug: owner.slug,
description: owner.description ?? undefined,
company_type: 'property_owner',
person_type: (owner.person_type || 'company') as 'company' | 'individual' | 'firm' | 'organization',
website_url: owner.website_url ?? undefined,
founded_year: owner.founded_year ?? undefined,
headquarters_location: owner.headquarters_location ?? undefined,
banner_image_url: owner.banner_image_url ?? undefined,
card_image_url: owner.card_image_url ?? undefined
}}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditModalOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</div>
);
}

1101
src-old/pages/RideDetail.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { AdminFormSkeleton } from '@/components/loading/PageSkeletons';
import { ArrowLeft, FerrisWheel, Building2, Edit } from 'lucide-react';
import { RideModel, Ride, Company } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { useTechnicalSpecifications } from '@/hooks/useTechnicalSpecifications';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ManufacturerPhotoGallery } from '@/components/companies/ManufacturerPhotoGallery';
// Lazy load admin form
const RideModelForm = lazy(() => import('@/components/admin/RideModelForm').then(m => ({ default: m.RideModelForm })));
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory';
import { MetaTags } from '@/components/seo';
export default function RideModelDetail() {
const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [model, setModel] = useState<RideModel | null>(null);
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [statistics, setStatistics] = useState({ rideCount: 0, photoCount: 0 });
// Fetch technical specifications from relational table
const { data: technicalSpecs } = useTechnicalSpecifications('ride_model', model?.id);
const fetchData = useCallback(async () => {
try {
// Fetch manufacturer
const { data: manufacturerData, error: manufacturerError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug || '')
.eq('company_type', 'manufacturer')
.maybeSingle();
if (manufacturerError) throw manufacturerError;
setManufacturer(manufacturerData);
if (manufacturerData) {
// Fetch ride model
const { data: modelData, error: modelError } = await supabase
.from('ride_models')
.select('*')
.eq('slug', modelSlug || '')
.eq('manufacturer_id', manufacturerData.id)
.maybeSingle();
if (modelError) throw modelError;
setModel(modelData as RideModel);
if (modelData) {
// Fetch rides using this model with proper joins
const { data: ridesData, error: ridesError } = await supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*),
ride_model:ride_models(id, name, slug, manufacturer_id, category)
`)
.eq('ride_model_id', modelData.id)
.order('name');
if (ridesError) throw ridesError;
setRides(ridesData as Ride[] || []);
// Fetch statistics
const { count: photoCount } = await supabase
.from('photos')
.select('*', { count: 'exact', head: true })
.eq('entity_type', 'ride_model')
.eq('entity_id', modelData.id);
setStatistics({
rideCount: ridesData?.length || 0,
photoCount: photoCount || 0
});
}
}
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Ride Model Data', metadata: { manufacturerSlug, modelSlug } });
} finally {
setLoading(false);
}
}, [manufacturerSlug, modelSlug]);
useEffect(() => {
if (manufacturerSlug && modelSlug) {
fetchData();
}
}, [manufacturerSlug, modelSlug, fetchData]);
const handleEditSubmit = async (data: any) => {
try {
if (!user || !model) return;
const submissionData = {
...data,
manufacturer_id: model.manufacturer_id,
};
const { submitRideModelUpdate } = await import('@/lib/entitySubmissionHelpers');
await submitRideModelUpdate(model.id, submissionData, user.id);
toast({
title: "Ride Model Updated",
description: "Your changes have been submitted for review."
});
setIsEditModalOpen(false);
fetchData();
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg || "Failed to update ride model.",
variant: "destructive"
});
}
};
const formatCategory = (category: string | null | undefined) => {
if (!category) return 'Unknown';
return category.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
};
const formatRideType = (type: string | null | undefined) => {
if (!type) return 'Unknown';
return type.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
};
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="h-64 bg-muted rounded"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-48 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!model || !manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Ride Model Not Found</h1>
<Button onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Models
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<MetaTags entityType="ride-model" entitySlug={modelSlug} />
<Header />
<main className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name} Models
</Button>
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride model")}
>
<Edit className="w-4 h-4 mr-2" />
Edit Model
</Button>
</div>
{/* Hero Section */}
<div className="mb-8">
{(model.banner_image_url || model.banner_image_id) && (
<div className="relative w-full h-64 mb-6 rounded-lg overflow-hidden">
<img
src={model.banner_image_url || (model.banner_image_id ? getCloudflareImageUrl(model.banner_image_id, 'banner') : '')}
alt={model.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">{model.name}</h1>
<VersionIndicator
entityType="ride_model"
entityId={model.id}
entityName={model.name}
compact
/>
</div>
<div className="flex items-center gap-2 mb-4">
<Building2 className="w-4 h-4 text-muted-foreground" />
<Button
variant="link"
className="p-0 h-auto text-lg"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}`)}
>
{manufacturer.name}
</Button>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-6">
<Badge variant="secondary" className="text-sm px-3 py-1">
{formatCategory(model.category)}
</Badge>
{model.ride_type && (
<Badge variant="outline" className="text-sm px-3 py-1">
{formatRideType(model.ride_type)}
</Badge>
)}
<Badge variant="outline" className="text-sm px-3 py-1">
{statistics.rideCount} {statistics.rideCount === 1 ? 'ride' : 'rides'}
</Badge>
</div>
</div>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">Rides ({statistics.rideCount})</TabsTrigger>
<TabsTrigger value="photos">Photos ({statistics.photoCount})</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{model.description && (
<Card>
<CardContent className="pt-6">
<h2 className="text-2xl font-semibold mb-4">About</h2>
<p className="text-muted-foreground whitespace-pre-wrap">{model.description}</p>
</CardContent>
</Card>
)}
{technicalSpecs && technicalSpecs.length > 0 && (
<Card>
<CardContent className="pt-6">
<h2 className="text-2xl font-semibold mb-4">Technical Specifications</h2>
<div className="grid md:grid-cols-2 gap-4">
{technicalSpecs.map((spec) => (
<div key={spec.id} className="flex justify-between py-2 border-b">
<span className="font-medium capitalize">
{spec.spec_name.replace(/_/g, ' ')}
</span>
<span className="text-muted-foreground">
{spec.spec_value} {spec.spec_unit || ''}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="rides">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Rides</h2>
<Button
variant="outline"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models/${modelSlug}/rides`)}
>
View All Rides
</Button>
</div>
<p className="text-muted-foreground">
View all {statistics.rideCount} rides using the {model.name} model
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="photos">
<ManufacturerPhotoGallery
manufacturerId={model.id}
manufacturerName={model.name}
/>
</TabsContent>
<TabsContent value="history">
<Card>
<CardContent className="pt-6">
<EntityVersionHistory
entityType="ride_model"
entityId={model.id}
entityName={model.name}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Edit Modal */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<Suspense fallback={<AdminFormSkeleton />}>
<RideModelForm
manufacturerName={manufacturer.name}
manufacturerId={manufacturer.id}
initialData={{
id: model.id,
name: model.name,
slug: model.slug,
category: model.category,
ride_type: model.ride_type,
description: model.description,
banner_image_url: model.banner_image_url,
card_image_url: model.card_image_url,
}}
onSubmit={handleEditSubmit}
onCancel={() => setIsEditModalOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</main>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useParams, useNavigate } from "react-router-dom";
import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/lib/supabaseClient";
import { Header } from "@/components/layout/Header";
import { RideCard } from "@/components/rides/RideCard";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ArrowLeft, Filter, SlidersHorizontal, Plus, FerrisWheel } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { RideForm } from '@/components/admin/RideForm';
import { useAuth } from '@/hooks/useAuth';
import { useAuthModal } from '@/hooks/useAuthModal';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import type { Ride, Company, RideModel } from "@/types/database";
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function RideModelRides() {
const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { requireAuth } = useAuthModal();
const [model, setModel] = useState<RideModel | null>(null);
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
// Update document title when model changes
useDocumentTitle(model ? `${model.name} - Rides` : 'Model Rides');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const fetchData = useCallback(async () => {
try {
setLoading(true);
// Fetch manufacturer from companies table
const { data: manufacturerData, error: mfgError } = await supabase
.from("companies")
.select("*")
.eq("slug", manufacturerSlug || '')
.eq("company_type", "manufacturer")
.maybeSingle();
if (mfgError) throw mfgError;
if (!manufacturerData) {
setLoading(false);
return;
}
// Fetch ride model
const { data: modelData, error: modelError } = await supabase
.from("ride_models")
.select("*")
.eq("slug", modelSlug || '')
.eq("manufacturer_id", manufacturerData.id)
.maybeSingle();
if (modelError) throw modelError;
if (!modelData) {
setLoading(false);
return;
}
// Enhanced query with filters and sort
let query = supabase
.from("rides")
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*),
ride_model:ride_models(id, name, slug, manufacturer_id, category)
`)
.eq("ride_model_id", modelData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
setManufacturer(manufacturerData);
setModel(modelData as RideModel);
setRides(ridesData as Ride[] || []);
} catch (error) {
handleNonCriticalError(error, { action: 'Fetch Model Rides Data', metadata: { manufacturerSlug, modelSlug } });
} finally {
setLoading(false);
}
}, [manufacturerSlug, modelSlug, sortBy, filterCategory, filterStatus]);
useEffect(() => {
if (manufacturerSlug && modelSlug) {
fetchData();
}
}, [manufacturerSlug, modelSlug, fetchData]);
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleCreateSubmit = async (data: any) => {
try {
if (!user || !model) return;
const submissionData = {
...data,
ride_model_id: model.id,
manufacturer_id: manufacturer!.id,
};
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
await submitRideCreation(submissionData, user.id);
toast({
title: "Ride Submitted",
description: "Your ride submission has been sent for review."
});
setIsCreateModalOpen(false);
fetchData();
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Error",
description: errorMsg || "Failed to submit ride.",
variant: "destructive"
});
}
};
if (loading) {
return (
<>
<Header />
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-64 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-64" />
))}
</div>
</div>
</>
);
}
if (!model || !manufacturer) {
return (
<>
<Header />
<div className="container mx-auto px-4 py-8">
<p className="text-muted-foreground">Model not found</p>
</div>
</>
);
}
return (
<>
<Header />
<div className="container mx-auto px-4 py-8">
<Button
variant="ghost"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models/${modelSlug}`)}
className="mb-6"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {model.name}
</Button>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">{model.name} Installations</h1>
</div>
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a new ride")}
>
<Plus className="w-4 h-4 mr-2" />
Add Ride
</Button>
</div>
<p className="text-lg text-muted-foreground mb-4">
All rides using the {model.name} by {manufacturer.name}
</p>
<Badge variant="secondary">{filteredRides.length} rides</Badge>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or park..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="roller_coaster">Roller Coasters</SelectItem>
<SelectItem value="flat_ride">Flat Rides</SelectItem>
<SelectItem value="water_ride">Water Rides</SelectItem>
<SelectItem value="dark_ride">Dark Rides</SelectItem>
<SelectItem value="kiddie_ride">Kiddie Rides</SelectItem>
<SelectItem value="transportation">Transportation</SelectItem>
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="operating">Operating</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
<SelectItem value="under_construction">Under Construction</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredRides.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 3xl:grid-cols-8 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{filteredRides.map((ride) => (
<RideCard key={ride.id} ride={ride} showParkName={true} />
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
No rides match your search criteria for the {model.name}
</p>
</div>
)}
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</div>
</>
);
}

545
src-old/pages/Rides.tsx Normal file
View File

@@ -0,0 +1,545 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Filter, SlidersHorizontal, FerrisWheel, Plus, ChevronDown, PanelLeftClose, PanelLeftOpen, Grid3X3, List } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { RideCard } from '@/components/rides/RideCard';
import { RideListView } from '@/components/rides/RideListView';
import { RideForm } from '@/components/admin/RideForm';
import { RideFilters, RideFilterState, defaultRideFilters } from '@/components/rides/RideFilters';
import { Ride, Park } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { useAuthModal } from '@/hooks/useAuthModal';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph';
import { useRides } from '@/hooks/rides/useRides';
import { Pagination } from '@/components/common/Pagination';
import { SubmissionErrorBoundary } from '@/components/error/SubmissionErrorBoundary';
export default function Rides() {
useDocumentTitle('Rides & Attractions');
const navigate = useNavigate();
const { user } = useAuth();
const { isModerator } = useUserRole();
const { requireAuth } = useAuthModal();
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filters, setFilters] = useState<RideFilterState>(defaultRideFilters);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('rides-sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
// Use TanStack Query hook for data fetching with caching
const { data: rides = [], isLoading: loading, error } = useRides();
useEffect(() => {
localStorage.setItem('rides-sidebar-collapsed', JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
// Show error toast if query fails
useEffect(() => {
if (error) {
toast({
title: "Error loading rides",
description: error instanceof Error ? error.message : 'Failed to load rides',
variant: "destructive"
});
}
}, [error]);
const handleCreateSubmit = async (data: any) => {
try {
const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
await submitRideCreation(data, user!.id);
toast({
title: "Ride Submitted",
description: "Your ride submission has been sent for moderation review.",
});
setIsCreateModalOpen(false);
} catch (error) {
const errorMsg = getErrorMessage(error);
toast({
title: "Submission Failed",
description: errorMsg || "Failed to submit ride.",
variant: "destructive"
});
}
};
const filteredAndSortedRides = React.useMemo(() => {
let filtered = rides.filter(ride => {
// Search filter
if (searchQuery) {
const searchTerm = searchQuery.toLowerCase();
const matchesSearch =
ride.name.toLowerCase().includes(searchTerm) ||
ride.park?.name?.toLowerCase().includes(searchTerm) ||
ride.manufacturer?.name?.toLowerCase().includes(searchTerm) ||
ride.designer?.name?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
// Category filter
if (filters.categories.length > 0 && !filters.categories.includes(ride.category)) {
return false;
}
// Status filter
if (filters.status !== 'all' && ride.status !== filters.status) {
return false;
}
// Country filter
if (filters.countries.length > 0) {
if (!ride.park?.location?.country || !filters.countries.includes(ride.park.location.country)) {
return false;
}
}
// States filter
if (filters.statesProvinces.length > 0) {
if (!ride.park?.location?.state_province || !filters.statesProvinces.includes(ride.park.location.state_province)) {
return false;
}
}
// Cities filter
if (filters.cities.length > 0) {
if (!ride.park?.location?.city || !filters.cities.includes(ride.park.location.city)) {
return false;
}
}
// Parks filter
if (filters.parks.length > 0) {
// Use park_id from the ride object, not the nested park
if (!ride.park_id || !filters.parks.includes(ride.park_id)) {
return false;
}
}
// Manufacturer filter
if (filters.manufacturers.length > 0) {
if (!ride.manufacturer?.id || !filters.manufacturers.includes(ride.manufacturer.id)) {
return false;
}
}
// Designer filter
if (filters.designers.length > 0) {
if (!ride.designer?.id || !filters.designers.includes(ride.designer.id)) {
return false;
}
}
// Coaster type filter
if (filters.coasterTypes.length > 0) {
// Assuming coaster_type is a field on the ride
if (!ride.coaster_type || !filters.coasterTypes.includes(ride.coaster_type)) {
return false;
}
}
// Seating type filter
if (filters.seatingTypes.length > 0) {
if (!ride.seating_type || !filters.seatingTypes.includes(ride.seating_type)) {
return false;
}
}
// Intensity level filter
if (filters.intensityLevels.length > 0) {
if (!ride.intensity_level || !filters.intensityLevels.includes(ride.intensity_level)) {
return false;
}
}
// Track material filter (array field)
if (filters.trackMaterials.length > 0) {
if (!ride.track_material || ride.track_material.length === 0 ||
!ride.track_material.some(material => filters.trackMaterials.includes(material))) {
return false;
}
}
// Support material filter (array field)
if (filters.supportMaterials.length > 0) {
if (!ride.support_material || ride.support_material.length === 0 ||
!ride.support_material.some(material => filters.supportMaterials.includes(material))) {
return false;
}
}
// Propulsion method filter (array field)
if (filters.propulsionMethods.length > 0) {
if (!ride.propulsion_method || ride.propulsion_method.length === 0 ||
!ride.propulsion_method.some(method => filters.propulsionMethods.includes(method))) {
return false;
}
}
// Speed filter
if (filters.minSpeed > 0 || filters.maxSpeed < 200) {
const speed = ride.max_speed_kmh || 0;
if (speed < filters.minSpeed || speed > filters.maxSpeed) {
return false;
}
}
// Height filter
if (filters.minHeight > 0 || filters.maxHeight < 150) {
const height = ride.max_height_meters || 0;
if (height < filters.minHeight || height > filters.maxHeight) {
return false;
}
}
// Length filter
if (filters.minLength > 0 || filters.maxLength < 3000) {
const length = ride.length_meters || 0;
if (length < filters.minLength || length > filters.maxLength) {
return false;
}
}
// Inversions filter
if (filters.minInversions > 0 || filters.maxInversions < 14) {
const inversions = ride.inversions || 0;
if (inversions < filters.minInversions || inversions > filters.maxInversions) {
return false;
}
}
// Has inversions checkbox
if (filters.hasInversions && (!ride.inversions || ride.inversions === 0)) {
return false;
}
// Opening date filter (timezone-independent string comparison)
if (filters.openingDateFrom || filters.openingDateTo) {
if (!ride.opening_date) {
return false;
}
// Direct YYYY-MM-DD string comparison (lexicographically correct)
if (filters.openingDateFrom && ride.opening_date < filters.openingDateFrom) {
return false;
}
if (filters.openingDateTo && ride.opening_date > filters.openingDateTo) {
return false;
}
}
// Operating only filter
if (filters.operatingOnly && ride.status !== 'operating') {
return false;
}
return true;
});
// Apply sorting
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'rating':
return (b.average_rating || 0) - (a.average_rating || 0);
case 'speed':
return (b.max_speed_kmh || 0) - (a.max_speed_kmh || 0);
case 'height':
return (b.max_height_meters || 0) - (a.max_height_meters || 0);
case 'reviews':
return (b.review_count || 0) - (a.review_count || 0);
default:
return 0;
}
});
return filtered;
}, [rides, searchQuery, sortBy, filters]);
// Pagination for display
const ITEMS_PER_PAGE = 24;
const paginatedRides = React.useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
return filteredAndSortedRides.slice(start, end);
}, [filteredAndSortedRides, currentPage]);
const totalPages = Math.ceil(filteredAndSortedRides.length / ITEMS_PER_PAGE);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [filters, sortBy, searchQuery]);
const generateDescription = () => {
if (!filteredAndSortedRides.length) return 'Discover thrilling rides and roller coasters worldwide';
const activeFilters: string[] = [];
if (filters.categories.length > 0) activeFilters.push(...filters.categories);
if (filters.status !== 'all') activeFilters.push(filters.status);
if (activeFilters.length > 0) {
return `Explore ${filteredAndSortedRides.length} ${activeFilters.join(' ')} rides and attractions`;
}
return `Explore ${filteredAndSortedRides.length} rides and roller coasters worldwide`;
};
useOpenGraph({
title: 'Rides & Attractions - ThrillWiki',
description: generateDescription(),
imageUrl: filteredAndSortedRides[0]?.banner_image_url ?? undefined,
imageId: filteredAndSortedRides[0]?.banner_image_id ?? undefined,
type: 'website',
enabled: !loading
});
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 md:px-6 lg:px-8 xl:px-8 2xl:px-10 py-6 xl:py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Rides & Attractions</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore amazing rides and attractions from theme parks worldwide
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="flex items-center justify-center text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1 whitespace-nowrap">
{filteredAndSortedRides.length} rides
</Badge>
<Badge variant="outline" className="flex items-center justify-center text-xs sm:text-sm px-2 py-0.5 whitespace-nowrap">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => requireAuth(() => setIsCreateModalOpen(true), "Sign in to add a new ride")}
className="gap-2"
>
<Plus className="w-4 h-4" />
Add Ride
</Button>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
{/* Desktop: Filter toggle on the left */}
<Button
variant="outline"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="shrink-0 gap-2 hidden lg:flex"
title={sidebarCollapsed ? "Show filters" : "Hide filters"}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
<span>Filters</span>
</Button>
{/* Search bar takes remaining space */}
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name, park, or manufacturer..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
{/* Sort controls - more compact */}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
<SelectItem value="speed">Speed</SelectItem>
<SelectItem value="height">Height</SelectItem>
<SelectItem value="reviews">Reviews</SelectItem>
</SelectContent>
</Select>
{/* Mobile filter toggle */}
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="gap-2 lg:hidden"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'grid' | 'list')} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid"><Grid3X3 className="w-4 h-4" /></TabsTrigger>
<TabsTrigger value="list"><List className="w-4 h-4" /></TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Mobile Filters */}
<div className="lg:hidden">
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<CollapsibleContent>
<Card>
<CardContent className="pt-6">
<RideFilters filters={filters} onFiltersChange={setFilters} rides={rides as any} />
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
</div>
{/* Main Content Area with Sidebar */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Desktop Filter Sidebar */}
{!sidebarCollapsed && (
<aside className="hidden lg:block flex-shrink-0 transition-all duration-300 lg:w-[340px] xl:w-[380px] 2xl:w-[420px]">
<div className="sticky top-24">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(true)}
title="Hide filters"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<RideFilters filters={filters} onFiltersChange={setFilters} rides={rides as any} />
</CardContent>
</Card>
</div>
</aside>
)}
{/* Results Area */}
<div className="flex-1 min-w-0">
{filteredAndSortedRides.length > 0 ? (
<div>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 3xl:grid-cols-7 gap-4 lg:gap-5 xl:gap-4 2xl:gap-5">
{paginatedRides.map((ride) => (
<RideCard key={ride.id} ride={ride as any} showParkName={true} />
))}
</div>
) : (
<RideListView rides={paginatedRides as any} onRideClick={(ride) => navigate(`/parks/${ride.park?.slug}/rides/${ride.slug}`)} />
)}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
isLoading={loading}
/>
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
Try adjusting your search criteria or filters
</p>
</div>
)}
</div>
</div>
{/* Create Modal */}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<SubmissionErrorBoundary>
<RideForm
onSubmit={handleCreateSubmit}
onCancel={() => setIsCreateModalOpen(false)}
isEditing={false}
/>
</SubmissionErrorBoundary>
</DialogContent>
</Dialog>
</main>
</div>
);
}

271
src-old/pages/Search.tsx Normal file
View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Search, Filter, SlidersHorizontal } from 'lucide-react';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
import { SearchFiltersComponent, SearchFilters } from '@/components/search/SearchFilters';
import { SearchSortOptions, SortOption } from '@/components/search/SearchSortOptions';
import { EnhancedSearchResults } from '@/components/search/EnhancedSearchResults';
import { useSearch, SearchResult } from '@/hooks/useSearch';
import { useOpenGraph } from '@/hooks/useOpenGraph';
export default function SearchPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const initialQuery = searchParams.get('q') || '';
const [activeTab, setActiveTab] = useState('all');
const [filters, setFilters] = useState<SearchFilters>({});
const [sort, setSort] = useState<SortOption>({ field: 'relevance', direction: 'desc' });
const [showFilters, setShowFilters] = useState(false);
const {
query,
setQuery,
results,
loading,
search
} = useSearch({
types: ['park', 'ride', 'company'],
limit: 50
});
useEffect(() => {
if (initialQuery) {
setQuery(initialQuery);
}
}, [initialQuery, setQuery]);
// Filter and sort results
const filteredAndSortedResults = (() => {
let filtered = results.filter(result =>
activeTab === 'all' || result.type === activeTab
);
// Apply filters
if (filters.country) {
filtered = filtered.filter(result =>
result.subtitle?.toLowerCase().includes(filters.country!.toLowerCase())
);
}
if (filters.stateProvince) {
filtered = filtered.filter(result =>
result.subtitle?.toLowerCase().includes(filters.stateProvince!.toLowerCase())
);
}
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) {
filtered = filtered.filter(result => {
if (!result.rating) return false;
const rating = result.rating;
const min = filters.ratingMin ?? 0;
const max = filters.ratingMax ?? 5;
return rating >= min && rating <= max;
});
}
// Type-safe helpers for sorting
const getReviewCount = (data: unknown): number => {
if (data && typeof data === 'object' && 'review_count' in data) {
const count = (data as { review_count?: number }).review_count;
return typeof count === 'number' ? count : 0;
}
return 0;
};
const getRideCount = (data: unknown): number => {
if (data && typeof data === 'object' && 'ride_count' in data) {
const count = (data as { ride_count?: number }).ride_count;
return typeof count === 'number' ? count : 0;
}
return 0;
};
const getOpeningDate = (data: unknown): number => {
if (data && typeof data === 'object' && 'opening_date' in data) {
const dateStr = (data as { opening_date?: string }).opening_date;
return dateStr ? new Date(dateStr).getTime() : 0;
}
return 0;
};
// Sort results
filtered.sort((a, b) => {
const direction = sort.direction === 'asc' ? 1 : -1;
switch (sort.field) {
case 'name':
return direction * a.title.localeCompare(b.title);
case 'rating':
return direction * ((b.rating || 0) - (a.rating || 0));
case 'reviews':
return direction * (getReviewCount(b.data) - getReviewCount(a.data));
case 'rides':
return direction * (getRideCount(b.data) - getRideCount(a.data));
case 'opening':
return direction * (getOpeningDate(b.data) - getOpeningDate(a.data));
default: // relevance
return 0; // Keep original order for relevance
}
});
return filtered;
})();
const resultCounts = {
all: results.length,
park: results.filter(r => r.type === 'park').length,
ride: results.filter(r => r.type === 'ride').length,
company: results.filter(r => r.type === 'company').length
};
useOpenGraph({
title: query ? `Search: "${query}" - ThrillWiki` : 'Search - ThrillWiki',
description: query
? `Found ${filteredAndSortedResults.length} results for "${query}"`
: 'Search theme parks, rides, and more on ThrillWiki',
type: 'website',
enabled: !loading
});
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
{/* Search Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<Search className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Search</h1>
</div>
{query && (
<p className="text-lg text-muted-foreground mb-6">
Showing {filteredAndSortedResults.length} results for "{query}"
</p>
)}
{/* Search Bar */}
<div className="max-w-2xl">
<AutocompleteSearch
placeholder="Search parks, rides, or companies..."
types={['park', 'ride', 'company']}
limit={8}
onSearch={(newQuery) => {
const params = new URLSearchParams();
params.set('q', newQuery);
navigate(`/search?${params.toString()}`, { replace: true });
}}
/>
</div>
</div>
{/* Results Section */}
{query && (
<div className="space-y-6">
{/* Tabs and Controls */}
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4 lg:w-auto">
<TabsTrigger value="all">
All ({resultCounts.all})
</TabsTrigger>
<TabsTrigger value="park">
Parks ({resultCounts.park})
</TabsTrigger>
<TabsTrigger value="ride">
Rides ({resultCounts.ride})
</TabsTrigger>
<TabsTrigger value="company">
Companies ({resultCounts.company})
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2 w-full lg:w-auto">
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<SlidersHorizontal className="w-4 h-4" />
Filters
</Button>
<SearchSortOptions
sort={sort}
onSortChange={setSort}
activeTab={activeTab}
/>
</div>
</div>
{/* Layout Grid */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Filters Sidebar */}
{showFilters && (
<div className="lg:col-span-1">
<SearchFiltersComponent
filters={filters}
onFiltersChange={setFilters}
activeTab={activeTab}
/>
</div>
)}
{/* Results */}
<div className={`${showFilters ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
{!loading && filteredAndSortedResults.length === 0 && query && (
<div className="text-center py-12">
<Search className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No results found</h3>
<p className="text-muted-foreground mb-4">
Try adjusting your search terms or filters
</p>
<div className="flex gap-2 justify-center">
<Button
onClick={() => {
setQuery('');
navigate('/search');
}}
variant="outline"
>
Clear search
</Button>
<Button
onClick={() => setFilters({})}
variant="outline"
>
Clear filters
</Button>
</div>
</div>
)}
<EnhancedSearchResults
results={filteredAndSortedResults}
loading={loading}
hasMore={false}
/>
</div>
</div>
</div>
)}
{/* Initial State */}
{!query && (
<div className="text-center py-12">
<Search className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">Start your search</h3>
<p className="text-muted-foreground">
Search for theme parks, rides, or companies to get started
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,362 @@
import { Header } from '@/components/layout/Header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, AlertTriangle, Camera, Star, MapPin, Settings, Building2, Globe } from 'lucide-react';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function SubmissionGuidelines() {
useDocumentTitle('Submission Guidelines');
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4">Submission Guidelines</h1>
<p className="text-xl text-muted-foreground">
Help us maintain the quality and accuracy of ThrillWiki by following these guidelines when contributing content.
</p>
</div>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
Park Creation & Editing Guidelines
</CardTitle>
<CardDescription>
Accurate park information helps visitors plan their trips
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Verify Basic Information</h4>
<p className="text-sm text-muted-foreground">
Double-check park name, location, opening dates, and operational status
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Use Official Sources</h4>
<p className="text-sm text-muted-foreground">
Reference park websites, press releases, and verified social media accounts
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Complete Contact Details</h4>
<p className="text-sm text-muted-foreground">
Include website, phone number, and email when publicly available
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Choose Representative Images</h4>
<p className="text-sm text-muted-foreground">
Select high-quality banner and card images that showcase the park's character
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Ride Creation & Editing Guidelines
</CardTitle>
<CardDescription>
Detailed ride information enhances the enthusiast experience
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Accurate Technical Specifications</h4>
<p className="text-sm text-muted-foreground">
Verify height, speed, length, inversions, and capacity from reliable sources
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Correct Categorization</h4>
<p className="text-sm text-muted-foreground">
Choose the most specific ride category and sub-type available
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Manufacturer Information</h4>
<p className="text-sm text-muted-foreground">
Include ride manufacturer and model when known and verifiable
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Current Status Updates</h4>
<p className="text-sm text-muted-foreground">
Keep operational status current (operating, closed, under construction, etc.)
</p>
</div>
</div>
<div className="mt-4">
<h4 className="font-medium mb-2">Required Fields</h4>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Ride Name</Badge>
<Badge variant="outline">Park Association</Badge>
<Badge variant="outline">Category</Badge>
<Badge variant="outline">Status</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5" />
Company & Manufacturer Guidelines
</CardTitle>
<CardDescription>
Comprehensive company information supports industry knowledge
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Company Classification</h4>
<p className="text-sm text-muted-foreground">
Specify company type (manufacturer, operator, designer) and person type
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Historical Accuracy</h4>
<p className="text-sm text-muted-foreground">
Verify founding year, headquarters location, and company history
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Official Information</h4>
<p className="text-sm text-muted-foreground">
Use company websites, press releases, and industry publications
</p>
</div>
</div>
<div className="mt-4">
<h4 className="font-medium mb-2">Required Fields</h4>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Company Name</Badge>
<Badge variant="outline">Company Type</Badge>
<Badge variant="outline">Person Type</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5" />
Location Guidelines
</CardTitle>
<CardDescription>
Precise location data improves search and discovery
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Geographic Accuracy</h4>
<p className="text-sm text-muted-foreground">
Verify country, state/province, city, and postal code information
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Coordinate Precision</h4>
<p className="text-sm text-muted-foreground">
Use accurate latitude and longitude coordinates when available
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Timezone Information</h4>
<p className="text-sm text-muted-foreground">
Include correct timezone data for operational hours and events
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star className="w-5 h-5" />
Writing Quality Reviews
</CardTitle>
<CardDescription>
Great reviews help other enthusiasts make informed decisions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Be Specific and Detailed</h4>
<p className="text-sm text-muted-foreground">
Describe the ride experience, intensity, theming, and what makes it unique
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Consider Different Perspectives</h4>
<p className="text-sm text-muted-foreground">
Think about families, thrill seekers, and first-time visitors
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<h4 className="font-medium">Include Practical Information</h4>
<p className="text-sm text-muted-foreground">
Mention wait times, best times to visit, height requirements, etc.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="w-5 h-5" />
Photo Submission Guidelines
</CardTitle>
<CardDescription>
High-quality photos enhance the ThrillWiki experience
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">Photo Requirements</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>• High resolution and good lighting</li>
<li>• Clear subject focus (rides, parks, attractions)</li>
<li>• No watermarks or heavy editing</li>
<li>• Must be your original photos or properly licensed</li>
</ul>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Landscape preferred</Badge>
<Badge variant="outline">Max 10MB per image</Badge>
<Badge variant="outline">JPG/PNG formats</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Content Standards
</CardTitle>
<CardDescription>
What to avoid when contributing to ThrillWiki
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-red-600 mb-2">Not Allowed</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
<li>• Offensive or inappropriate content</li>
<li>• False or misleading information</li>
<li>• Spam or promotional content</li>
<li>• Copyrighted material without permission</li>
<li>• Personal attacks or harassment</li>
</ul>
</div>
<div>
<h4 className="font-medium text-green-600 mb-2">Encouraged</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
<li>• Honest, balanced reviews</li>
<li>• Constructive feedback</li>
<li>• Helpful tips and insights</li>
<li>• Accurate park information</li>
<li>• Respectful community interaction</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Moderation Process</CardTitle>
<CardDescription>
How we maintain content quality on ThrillWiki
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
All submissions are reviewed by our community moderation team. Content that doesn't meet our guidelines may be:
</p>
<div className="grid sm:grid-cols-3 gap-4 text-sm">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="font-medium">Pending Review</div>
<div className="text-muted-foreground">Awaiting approval</div>
</div>
<div className="text-center p-3 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="font-medium text-green-700 dark:text-green-400">Approved</div>
<div className="text-muted-foreground">Published to site</div>
</div>
<div className="text-center p-3 bg-red-50 dark:bg-red-950/20 rounded-lg">
<div className="font-medium text-red-700 dark:text-red-400">Rejected</div>
<div className="text-muted-foreground">Needs revision</div>
</div>
</div>
</CardContent>
</Card>
<div className="mt-8 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
Questions about these guidelines? Contact our moderation team for clarification.
</p>
</div>
</div>
</main>
</div>
);
}

70
src-old/pages/Terms.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { Header } from '@/components/layout/Header';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function Terms() {
useDocumentTitle('Terms of Service');
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="prose dark:prose-invert max-w-none">
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
<p className="text-muted-foreground mb-4">
By accessing and using ThrillWiki, you accept and agree to be bound by the terms and provision of this agreement.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. User Content</h2>
<p className="text-muted-foreground mb-4">
Users are responsible for all content they submit, including reviews, photos, and park information. Content must be accurate, respectful, and relevant to theme parks and rides.
</p>
<ul className="list-disc pl-6 text-muted-foreground mb-4">
<li>No false or misleading information</li>
<li>No offensive, harmful, or inappropriate content</li>
<li>No spam or promotional content</li>
<li>Respect intellectual property rights</li>
</ul>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. Community Guidelines</h2>
<p className="text-muted-foreground mb-4">
ThrillWiki is a community-driven platform. We expect all users to contribute positively and help maintain the quality of information.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Moderation</h2>
<p className="text-muted-foreground mb-4">
We reserve the right to moderate, edit, or remove any content that violates these terms or our community guidelines.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Account Termination</h2>
<p className="text-muted-foreground mb-4">
We may suspend or terminate accounts that repeatedly violate these terms or engage in harmful behavior.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Changes to Terms</h2>
<p className="text-muted-foreground mb-4">
We may update these terms from time to time. Continued use of ThrillWiki constitutes acceptance of any changes.
</p>
</section>
<div className="mt-12 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
Last updated: January 2024
</p>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Settings, User, Shield, Eye, Bell, MapPin, Download } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { Navigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { AccountProfileTab } from '@/components/settings/AccountProfileTab';
import { SecurityTab } from '@/components/settings/SecurityTab';
import { PrivacyTab } from '@/components/settings/PrivacyTab';
import { NotificationsTab } from '@/components/settings/NotificationsTab';
import { LocationTab } from '@/components/settings/LocationTab';
import { DataExportTab } from '@/components/settings/DataExportTab';
export default function UserSettings() {
const { user, loading: authLoading } = useAuth();
const { data: profile, isLoading: profileLoading } = useProfile(user?.id);
const [activeTab, setActiveTab] = useState('profile');
const loading = authLoading || profileLoading;
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="h-96 bg-muted rounded"></div>
</div>
</div>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/auth" replace />;
}
const tabs = [
{
id: 'profile',
label: 'Account & Profile',
icon: User,
component: AccountProfileTab
},
{
id: 'security',
label: 'Security',
icon: Shield,
component: SecurityTab
},
{
id: 'privacy',
label: 'Privacy',
icon: Eye,
component: PrivacyTab
},
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
component: NotificationsTab
},
{
id: 'location',
label: 'Location & Info',
icon: MapPin,
component: LocationTab
},
{
id: 'data',
label: 'Data & Export',
icon: Download,
component: DataExportTab
}
];
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<Settings className="w-8 h-8 text-primary" />
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">Manage your account preferences and privacy settings</p>
</div>
</div>
{/* Settings Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6 h-auto p-1">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<TabsTrigger
key={tab.id}
value={tab.id}
className="flex flex-col sm:flex-row items-center gap-2 h-auto py-3 px-2 text-xs sm:text-sm"
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline lg:inline">{tab.label}</span>
<span className="sm:hidden lg:hidden">{tab.label.split(' ')[0]}</span>
</TabsTrigger>
);
})}
</TabsList>
{tabs.map((tab) => {
const Component = tab.component;
return (
<TabsContent
key={tab.id}
value={tab.id}
className="focus-visible:outline-none"
>
<Component />
</TabsContent>
);
})}
</Tabs>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Loader2, Mail } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { useUserRole } from '@/hooks/useUserRole';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function AdminEmailSettings() {
useDocumentTitle('Email Settings - Admin');
const queryClient = useQueryClient();
const { isSuperuser, loading: rolesLoading } = useUserRole();
const [signature, setSignature] = useState('');
// Fetch email signature
const { data: signatureSetting, isLoading } = useQuery({
queryKey: ['admin-email-signature'],
queryFn: async () => {
const { data, error } = await supabase
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'email.signature')
.single();
if (error && error.code !== 'PGRST116') { // PGRST116 = no rows returned
throw error;
}
// Type guard for the setting value
const settingValue = data?.setting_value as { signature?: string } | null;
return settingValue?.signature || '';
},
});
useEffect(() => {
if (signatureSetting !== undefined) {
setSignature(signatureSetting);
}
}, [signatureSetting]);
// Update email signature mutation
const updateSignatureMutation = useMutation({
mutationFn: async (newSignature: string) => {
const { data: existing } = await supabase
.from('admin_settings')
.select('id')
.eq('setting_key', 'email.signature')
.single();
if (existing) {
// Update existing
const { error } = await supabase
.from('admin_settings')
.update({
setting_value: { signature: newSignature },
updated_at: new Date().toISOString(),
})
.eq('setting_key', 'email.signature');
if (error) throw error;
} else {
// Insert new
const { error } = await supabase
.from('admin_settings')
.insert({
setting_key: 'email.signature',
setting_value: { signature: newSignature },
category: 'email',
description: 'Email signature automatically appended to all contact submission replies',
});
if (error) throw error;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-email-signature'] });
handleSuccess('Saved', 'Email signature has been updated successfully');
},
onError: (error) => {
handleError(error, { action: 'update_email_signature' });
},
});
const handleSave = () => {
updateSignatureMutation.mutate(signature);
};
// Show loading state while roles are being fetched
if (rolesLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Superuser-only access check
if (!isSuperuser()) {
return (
<AdminLayout>
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md">
<CardContent className="pt-6 text-center">
<Mail className="h-12 w-12 text-destructive mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Access Denied</h2>
<p className="text-muted-foreground">
Email settings can only be managed by superusers.
</p>
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">Email Settings</h1>
<p className="text-muted-foreground">
Configure automatic email signature for contact submission replies
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email Signature
</CardTitle>
<CardDescription>
This signature will be automatically appended to all email replies sent to users from the Contact Submissions page.
The signature will be added after a separator line.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
<strong>Preview:</strong> The signature will appear as:
<div className="mt-2 p-3 bg-muted rounded-md text-sm font-mono whitespace-pre-wrap">
[Your reply message]
{'\n\n'}---
{'\n'}[Admin Display Name]
{signature ? `\n${signature}` : '\n[No signature set]'}
</div>
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="signature">Signature Text</Label>
<Textarea
id="signature"
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Best regards,&#10;The ThrillWiki Team&#10;&#10;Need help? Reply to this email or visit https://thrillwiki.com/contact"
rows={8}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Use plain text. Line breaks will be preserved. You can include team name, contact info, or helpful links.
</p>
</div>
<div className="flex items-center gap-3 pt-4">
<Button
onClick={handleSave}
disabled={updateSignatureMutation.isPending || isLoading}
>
{updateSignatureMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
Save Signature
</Button>
{signature !== signatureSetting && (
<Button
variant="outline"
onClick={() => setSignature(signatureSetting || '')}
disabled={updateSignatureMutation.isPending}
>
Reset Changes
</Button>
)}
</div>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Search } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { toast } from 'sonner';
export default function ErrorLookup() {
const [errorId, setErrorId] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSearch = async () => {
if (!errorId.trim()) {
toast.error('Please enter an error ID');
return;
}
setLoading(true);
try {
// Search by partial or full request ID
const { data, error } = await supabase
.from('request_metadata')
.select(`
*,
request_breadcrumbs(
timestamp,
category,
message,
level,
sequence_order
)
`)
.ilike('request_id', `${errorId}%`)
.not('error_type', 'is', null)
.limit(1)
.single();
if (error || !data) {
toast.error('Error not found', {
description: 'No error found with this ID. Please check the ID and try again.',
});
return;
}
// Navigate to error monitoring with this error pre-selected
navigate('/admin/error-monitoring', { state: { selectedErrorId: data.request_id } });
} catch (err) {
toast.error('Search failed', {
description: 'An error occurred while searching. Please try again.',
});
} finally {
setLoading(false);
}
};
return (
<AdminLayout>
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Error Lookup</h1>
<p className="text-muted-foreground">
Search for specific errors by their reference ID
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Search by Error ID</CardTitle>
<CardDescription>
Enter the error reference ID provided to users (first 8 characters are sufficient)
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="e.g., a3f7b2c1"
value={errorId}
onChange={(e) => setErrorId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="font-mono"
/>
<Button
onClick={handleSearch}
loading={loading}
loadingText="Searching..."
trackingLabel="error-lookup-search"
>
<Search className="w-4 h-4 mr-2" />
Search
</Button>
</div>
</CardContent>
</Card>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,373 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle, XCircle } from 'lucide-react';
import { RefreshButton } from '@/components/ui/refresh-button';
import { ErrorDetailsModal } from '@/components/admin/ErrorDetailsModal';
import { ApprovalFailureModal } from '@/components/admin/ApprovalFailureModal';
import { ErrorAnalytics } from '@/components/admin/ErrorAnalytics';
import { PipelineHealthAlerts } from '@/components/admin/PipelineHealthAlerts';
import { format } from 'date-fns';
// Helper to calculate date threshold for filtering
const getDateThreshold = (range: '1h' | '24h' | '7d' | '30d'): string => {
const now = new Date();
const msMap = {
'1h': 60 * 60 * 1000, // 1 hour in milliseconds
'24h': 24 * 60 * 60 * 1000, // 1 day
'7d': 7 * 24 * 60 * 60 * 1000, // 7 days
'30d': 30 * 24 * 60 * 60 * 1000, // 30 days
};
const threshold = new Date(now.getTime() - msMap[range]);
return threshold.toISOString();
};
interface EnrichedApprovalFailure {
id: string;
submission_id: string;
moderator_id: string;
submitter_id: string;
items_count: number;
duration_ms: number | null;
error_message: string | null;
request_id: string | null;
rollback_triggered: boolean | null;
created_at: string | null;
success: boolean;
moderator?: {
user_id: string;
username: string | null;
avatar_url: string | null;
};
submission?: {
id: string;
submission_type: string;
user_id: string;
};
}
export default function ErrorMonitoring() {
const [selectedError, setSelectedError] = useState<any>(null);
const [selectedFailure, setSelectedFailure] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState('');
const [errorTypeFilter, setErrorTypeFilter] = useState<string>('all');
const [dateRange, setDateRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
// Fetch recent errors
const { data: errors, isLoading, refetch, isFetching } = useQuery({
queryKey: ['admin-errors', dateRange, errorTypeFilter, searchTerm],
queryFn: async () => {
let query = supabase
.from('request_metadata')
.select(`
*,
request_breadcrumbs(
timestamp,
category,
message,
level,
sequence_order
)
`)
.not('error_type', 'is', null)
.gte('created_at', getDateThreshold(dateRange))
.order('created_at', { ascending: false })
.limit(100);
if (errorTypeFilter !== 'all') {
query = query.eq('error_type', errorTypeFilter);
}
if (searchTerm) {
query = query.or(`request_id.ilike.%${searchTerm}%,error_message.ilike.%${searchTerm}%,endpoint.ilike.%${searchTerm}%`);
}
const { data, error } = await query;
if (error) throw error;
return data;
},
refetchInterval: 30000, // Auto-refresh every 30 seconds
});
// Fetch error summary
const { data: errorSummary } = useQuery({
queryKey: ['error-summary'],
queryFn: async () => {
const { data, error } = await supabase
.from('error_summary')
.select('*');
if (error) throw error;
return data;
},
});
// Fetch approval metrics (last 24h)
const { data: approvalMetrics } = useQuery({
queryKey: ['approval-metrics'],
queryFn: async () => {
const { data, error } = await supabase
.from('approval_transaction_metrics')
.select('id, success, duration_ms, created_at')
.gte('created_at', getDateThreshold('24h'))
.order('created_at', { ascending: false })
.limit(1000);
if (error) throw error;
return data;
},
});
// Fetch approval failures
const { data: approvalFailures, refetch: refetchFailures, isFetching: isFetchingFailures } = useQuery<EnrichedApprovalFailure[]>({
queryKey: ['approval-failures', dateRange, searchTerm],
queryFn: async () => {
let query = supabase
.from('approval_transaction_metrics')
.select('*')
.eq('success', false)
.gte('created_at', getDateThreshold(dateRange))
.order('created_at', { ascending: false })
.limit(50);
if (searchTerm) {
query = query.or(`submission_id.ilike.%${searchTerm}%,error_message.ilike.%${searchTerm}%`);
}
const { data, error } = await query;
if (error) throw error;
// Fetch moderator and submission data separately
if (data && data.length > 0) {
const moderatorIds = [...new Set(data.map(f => f.moderator_id))];
const submissionIds = [...new Set(data.map(f => f.submission_id))];
const [moderatorsData, submissionsData] = await Promise.all([
supabase.from('profiles').select('user_id, username, avatar_url').in('user_id', moderatorIds),
supabase.from('content_submissions').select('id, submission_type, user_id').in('id', submissionIds)
]);
// Enrich data with moderator and submission info
return data.map(failure => ({
...failure,
moderator: moderatorsData.data?.find(m => m.user_id === failure.moderator_id),
submission: submissionsData.data?.find(s => s.id === failure.submission_id)
})) as EnrichedApprovalFailure[];
}
return (data || []) as EnrichedApprovalFailure[];
},
refetchInterval: 30000,
});
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Error Monitoring</h1>
<p className="text-muted-foreground">Track and analyze application errors</p>
</div>
<RefreshButton
onRefresh={async () => { await refetch(); }}
isLoading={isFetching}
variant="outline"
size="sm"
/>
</div>
{/* Pipeline Health Alerts */}
<PipelineHealthAlerts />
{/* Analytics Section */}
<ErrorAnalytics errorSummary={errorSummary} approvalMetrics={approvalMetrics} />
{/* Tabs for Errors and Approval Failures */}
<Tabs defaultValue="errors" className="w-full">
<TabsList>
<TabsTrigger value="errors">Application Errors</TabsTrigger>
<TabsTrigger value="approvals">Approval Failures</TabsTrigger>
</TabsList>
<TabsContent value="errors" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Error Log</CardTitle>
<CardDescription>Recent errors across the application</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-6">
<div className="flex-1">
<Input
placeholder="Search by request ID, endpoint, or error message..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<Select value={dateRange} onValueChange={(v: any) => setDateRange(v)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="24h">Last 24 Hours</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
<Select value={errorTypeFilter} onValueChange={setErrorTypeFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Error type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="FunctionsFetchError">Functions Fetch</SelectItem>
<SelectItem value="FunctionsHttpError">Functions HTTP</SelectItem>
<SelectItem value="Error">Generic Error</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading errors...</div>
) : errors && errors.length > 0 ? (
<div className="space-y-2">
{errors.map((error) => (
<div
key={error.id}
onClick={() => setSelectedError(error)}
className="p-4 border rounded-lg hover:bg-accent cursor-pointer transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="w-4 h-4 text-destructive" />
<span className="font-medium">{error.error_type}</span>
<Badge variant="outline" className="text-xs">
{error.endpoint}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2">
{error.error_message}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>ID: {error.request_id.slice(0, 8)}</span>
<span>{format(new Date(error.created_at), 'PPp')}</span>
{error.duration_ms != null && <span>{error.duration_ms}ms</span>}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No errors found for the selected filters
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="approvals" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Approval Failures</CardTitle>
<CardDescription>Failed approval transactions requiring investigation</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-6">
<div className="flex-1">
<Input
placeholder="Search by submission ID or error message..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<Select value={dateRange} onValueChange={(v: any) => setDateRange(v)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="24h">Last 24 Hours</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
</div>
{isFetchingFailures ? (
<div className="text-center py-8 text-muted-foreground">Loading approval failures...</div>
) : approvalFailures && approvalFailures.length > 0 ? (
<div className="space-y-2">
{approvalFailures.map((failure) => (
<div
key={failure.id}
onClick={() => setSelectedFailure(failure)}
className="p-4 border rounded-lg hover:bg-accent cursor-pointer transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-destructive" />
<span className="font-medium">Approval Failed</span>
<Badge variant="outline" className="text-xs">
{failure.submission?.submission_type || 'Unknown'}
</Badge>
{failure.rollback_triggered && (
<Badge variant="destructive" className="text-xs">
Rollback
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-2">
{failure.error_message || 'No error message available'}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>Moderator: {failure.moderator?.username || 'Unknown'}</span>
<span>{failure.created_at && format(new Date(failure.created_at), 'PPp')}</span>
{failure.duration_ms != null && <span>{failure.duration_ms}ms</span>}
<span>{failure.items_count} items</span>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No approval failures found for the selected filters
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Error Details Modal */}
{selectedError && (
<ErrorDetailsModal
error={selectedError}
onClose={() => setSelectedError(null)}
/>
)}
{/* Approval Failure Modal */}
{selectedFailure && (
<ApprovalFailureModal
failure={selectedFailure}
onClose={() => setSelectedFailure(null)}
/>
)}
</AdminLayout>
);
}