mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 03:11:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
190
src-old/pages/Admin.tsx
Normal file
190
src-old/pages/Admin.tsx
Normal 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
382
src-old/pages/AdminBlog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src-old/pages/AdminDashboard.tsx
Normal file
305
src-old/pages/AdminDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src-old/pages/AdminModeration.tsx
Normal file
81
src-old/pages/AdminModeration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src-old/pages/AdminReports.tsx
Normal file
82
src-old/pages/AdminReports.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
984
src-old/pages/AdminSettings.tsx
Normal file
984
src-old/pages/AdminSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src-old/pages/AdminSystemLog.tsx
Normal file
105
src-old/pages/AdminSystemLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src-old/pages/AdminUsers.tsx
Normal file
63
src-old/pages/AdminUsers.tsx
Normal 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
661
src-old/pages/Auth.tsx
Normal 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>;
|
||||
}
|
||||
343
src-old/pages/AuthCallback.tsx
Normal file
343
src-old/pages/AuthCallback.tsx
Normal 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
144
src-old/pages/BlogIndex.tsx
Normal 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
148
src-old/pages/BlogPost.tsx
Normal 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
120
src-old/pages/Contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
src-old/pages/DesignerDetail.tsx
Normal file
388
src-old/pages/DesignerDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
343
src-old/pages/DesignerRides.tsx
Normal file
343
src-old/pages/DesignerRides.tsx
Normal 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
370
src-old/pages/Designers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src-old/pages/ForceLogout.tsx
Normal file
55
src-old/pages/ForceLogout.tsx
Normal 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
17
src-old/pages/Index.tsx
Normal 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;
|
||||
409
src-old/pages/ManufacturerDetail.tsx
Normal file
409
src-old/pages/ManufacturerDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
src-old/pages/ManufacturerModels.tsx
Normal file
310
src-old/pages/ManufacturerModels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
343
src-old/pages/ManufacturerRides.tsx
Normal file
343
src-old/pages/ManufacturerRides.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
src-old/pages/Manufacturers.tsx
Normal file
388
src-old/pages/Manufacturers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src-old/pages/NotFound.tsx
Normal file
27
src-old/pages/NotFound.tsx
Normal 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;
|
||||
468
src-old/pages/OperatorDetail.tsx
Normal file
468
src-old/pages/OperatorDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
src-old/pages/OperatorParks.tsx
Normal file
295
src-old/pages/OperatorParks.tsx
Normal 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
394
src-old/pages/Operators.tsx
Normal 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;
|
||||
295
src-old/pages/OwnerParks.tsx
Normal file
295
src-old/pages/OwnerParks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
680
src-old/pages/ParkDetail.tsx
Normal file
680
src-old/pages/ParkDetail.tsx
Normal 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>;
|
||||
}
|
||||
259
src-old/pages/ParkOwners.tsx
Normal file
259
src-old/pages/ParkOwners.tsx
Normal 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
370
src-old/pages/ParkRides.tsx
Normal 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
595
src-old/pages/Parks.tsx
Normal 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
88
src-old/pages/Privacy.tsx
Normal 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
1125
src-old/pages/Profile.tsx
Normal file
File diff suppressed because it is too large
Load Diff
468
src-old/pages/PropertyOwnerDetail.tsx
Normal file
468
src-old/pages/PropertyOwnerDetail.tsx
Normal 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
1101
src-old/pages/RideDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
366
src-old/pages/RideModelDetail.tsx
Normal file
366
src-old/pages/RideModelDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src-old/pages/RideModelRides.tsx
Normal file
308
src-old/pages/RideModelRides.tsx
Normal 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
545
src-old/pages/Rides.tsx
Normal 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
271
src-old/pages/Search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
src-old/pages/SubmissionGuidelines.tsx
Normal file
362
src-old/pages/SubmissionGuidelines.tsx
Normal 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
70
src-old/pages/Terms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src-old/pages/UserSettings.tsx
Normal file
132
src-old/pages/UserSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1384
src-old/pages/admin/AdminContact.tsx
Normal file
1384
src-old/pages/admin/AdminContact.tsx
Normal file
File diff suppressed because it is too large
Load Diff
201
src-old/pages/admin/AdminEmailSettings.tsx
Normal file
201
src-old/pages/admin/AdminEmailSettings.tsx
Normal 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, The ThrillWiki Team 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>
|
||||
);
|
||||
}
|
||||
101
src-old/pages/admin/ErrorLookup.tsx
Normal file
101
src-old/pages/admin/ErrorLookup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
373
src-old/pages/admin/ErrorMonitoring.tsx
Normal file
373
src-old/pages/admin/ErrorMonitoring.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user