Refactor code structure and remove redundant changes

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

42
src-old/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

431
src-old/App.tsx Normal file
View File

@@ -0,0 +1,431 @@
import { lazy, Suspense, useEffect, useRef } from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
import { APIConnectivityProvider, useAPIConnectivity } from "@/contexts/APIConnectivityContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
import { Footer } from "@/components/layout/Footer";
import { PageLoader } from "@/components/loading/PageSkeletons";
import { RouteErrorBoundary } from "@/components/error/RouteErrorBoundary";
import { AdminErrorBoundary } from "@/components/error/AdminErrorBoundary";
import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
import { breadcrumb } from "@/lib/errorBreadcrumbs";
import { handleError } from "@/lib/errorHandler";
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
import { APIStatusBanner } from "@/components/ui/api-status-banner";
import { ResilienceProvider } from "@/components/layout/ResilienceProvider";
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
import { useVersionCheck } from "@/hooks/useVersionCheck";
import { cn } from "@/lib/utils";
// Core routes (eager-loaded for best UX)
import Index from "./pages/Index";
import Parks from "./pages/Parks";
import Rides from "./pages/Rides";
import Search from "./pages/Search";
import Auth from "./pages/Auth";
// Temporary test component for error logging verification
import { TestErrorLogging } from "./test-error-logging";
// Detail routes (lazy-loaded)
const ParkDetail = lazy(() => import("./pages/ParkDetail"));
const RideDetail = lazy(() => import("./pages/RideDetail"));
const ParkRides = lazy(() => import("./pages/ParkRides"));
const Manufacturers = lazy(() => import("./pages/Manufacturers"));
const ManufacturerDetail = lazy(() => import("./pages/ManufacturerDetail"));
const ManufacturerRides = lazy(() => import("./pages/ManufacturerRides"));
const ManufacturerModels = lazy(() => import("./pages/ManufacturerModels"));
const RideModelDetail = lazy(() => import("./pages/RideModelDetail"));
const RideModelRides = lazy(() => import("./pages/RideModelRides"));
const Designers = lazy(() => import("./pages/Designers"));
const DesignerDetail = lazy(() => import("./pages/DesignerDetail"));
const DesignerRides = lazy(() => import("./pages/DesignerRides"));
const ParkOwners = lazy(() => import("./pages/ParkOwners"));
const PropertyOwnerDetail = lazy(() => import("./pages/PropertyOwnerDetail"));
const OwnerParks = lazy(() => import("./pages/OwnerParks"));
const Operators = lazy(() => import("./pages/Operators"));
const OperatorDetail = lazy(() => import("./pages/OperatorDetail"));
const OperatorParks = lazy(() => import("./pages/OperatorParks"));
const BlogIndex = lazy(() => import("./pages/BlogIndex"));
const BlogPost = lazy(() => import("./pages/BlogPost"));
const Terms = lazy(() => import("./pages/Terms"));
const Privacy = lazy(() => import("./pages/Privacy"));
const SubmissionGuidelines = lazy(() => import("./pages/SubmissionGuidelines"));
const Contact = lazy(() => import("./pages/Contact"));
// Admin routes (lazy-loaded - heavy bundle)
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const AdminModeration = lazy(() => import("./pages/AdminModeration"));
const AdminReports = lazy(() => import("./pages/AdminReports"));
const AdminSystemLog = lazy(() => import("./pages/AdminSystemLog"));
const AdminUsers = lazy(() => import("./pages/AdminUsers"));
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
// User routes (lazy-loaded)
const Profile = lazy(() => import("./pages/Profile"));
const UserSettings = lazy(() => import("./pages/UserSettings"));
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
// Utility routes (lazy-loaded)
const NotFound = lazy(() => import("./pages/NotFound"));
const ForceLogout = lazy(() => import("./pages/ForceLogout"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false, // Disable automatic refetch on tab focus
refetchOnMount: true, // Keep refetch on component mount
refetchOnReconnect: true, // Keep refetch on network reconnect
retry: 1, // Keep retry attempts
staleTime: 30000, // 30 seconds - queries stay fresh for 30s
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins
},
mutations: {
onError: (error: unknown, variables: unknown, context: unknown) => {
// Track mutation errors with breadcrumbs
const contextObj = context as { endpoint?: string } | undefined;
const errorObj = error as { status?: number } | undefined;
breadcrumb.apiCall(
contextObj?.endpoint || 'mutation',
'MUTATION',
errorObj?.status || 500
);
// Handle error with tracking
handleError(error, {
action: 'Mutation failed',
metadata: {
variables,
context,
},
});
},
},
},
});
// Navigation tracking component - must be inside Router context
function NavigationTracker() {
const location = useLocation();
const prevLocation = useRef<string>('');
useEffect(() => {
const from = prevLocation.current || undefined;
breadcrumb.navigation(location.pathname, from);
prevLocation.current = location.pathname;
// Clear chunk load reload flag on successful navigation
sessionStorage.removeItem('chunk-load-reload');
}, [location.pathname]);
return null;
}
function AppContent(): React.JSX.Element {
// Check if API status banner is visible to add padding
const { isAPIReachable, isBannerDismissed } = useAPIConnectivity();
const showBanner = !isAPIReachable && !isBannerDismissed;
// Preload admin routes for moderators/admins
useAdminRoutePreload();
// Monitor for new deployments
useVersionCheck();
return (
<TooltipProvider>
<ResilienceProvider>
<APIStatusBanner />
<div className={cn(showBanner && "pt-20")}>
<NavigationTracker />
<LocationAutoDetectProvider />
<RetryStatusIndicator />
<Toaster />
<Sonner />
<div className="min-h-screen flex flex-col">
<div className="flex-1">
<Suspense fallback={<PageLoader />}>
<RouteErrorBoundary>
<Routes>
{/* Core routes - eager loaded */}
<Route path="/" element={<Index />} />
<Route path="/parks" element={<Parks />} />
<Route path="/rides" element={<Rides />} />
<Route path="/search" element={<Search />} />
<Route path="/auth" element={<Auth />} />
{/* Detail routes with entity error boundaries */}
<Route
path="/parks/:slug"
element={
<EntityErrorBoundary entityType="park">
<ParkDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/parks/:parkSlug/rides"
element={
<EntityErrorBoundary entityType="park">
<ParkRides />
</EntityErrorBoundary>
}
/>
<Route
path="/parks/:parkSlug/rides/:rideSlug"
element={
<EntityErrorBoundary entityType="ride">
<RideDetail />
</EntityErrorBoundary>
}
/>
<Route path="/manufacturers" element={<Manufacturers />} />
<Route
path="/manufacturers/:slug"
element={
<EntityErrorBoundary entityType="manufacturer">
<ManufacturerDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/manufacturers/:manufacturerSlug/rides"
element={
<EntityErrorBoundary entityType="manufacturer">
<ManufacturerRides />
</EntityErrorBoundary>
}
/>
<Route
path="/manufacturers/:manufacturerSlug/models"
element={
<EntityErrorBoundary entityType="manufacturer">
<ManufacturerModels />
</EntityErrorBoundary>
}
/>
<Route
path="/manufacturers/:manufacturerSlug/models/:modelSlug"
element={
<EntityErrorBoundary entityType="manufacturer">
<RideModelDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/manufacturers/:manufacturerSlug/models/:modelSlug/rides"
element={
<EntityErrorBoundary entityType="manufacturer">
<RideModelRides />
</EntityErrorBoundary>
}
/>
<Route path="/designers" element={<Designers />} />
<Route
path="/designers/:slug"
element={
<EntityErrorBoundary entityType="designer">
<DesignerDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/designers/:designerSlug/rides"
element={
<EntityErrorBoundary entityType="designer">
<DesignerRides />
</EntityErrorBoundary>
}
/>
<Route path="/owners" element={<ParkOwners />} />
<Route
path="/owners/:slug"
element={
<EntityErrorBoundary entityType="owner">
<PropertyOwnerDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/owners/:ownerSlug/parks"
element={
<EntityErrorBoundary entityType="owner">
<OwnerParks />
</EntityErrorBoundary>
}
/>
<Route path="/operators" element={<Operators />} />
<Route
path="/operators/:slug"
element={
<EntityErrorBoundary entityType="operator">
<OperatorDetail />
</EntityErrorBoundary>
}
/>
<Route
path="/operators/:operatorSlug/parks"
element={
<EntityErrorBoundary entityType="operator">
<OperatorParks />
</EntityErrorBoundary>
}
/>
<Route path="/blog" element={<BlogIndex />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/submission-guidelines" element={<SubmissionGuidelines />} />
<Route path="/contact" element={<Contact />} />
{/* User routes - lazy loaded */}
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} />
<Route path="/settings" element={<UserSettings />} />
{/* Admin routes with admin error boundaries */}
<Route
path="/admin"
element={
<AdminErrorBoundary section="Dashboard">
<AdminDashboard />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/moderation"
element={
<AdminErrorBoundary section="Moderation Queue">
<AdminModeration />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/reports"
element={
<AdminErrorBoundary section="Reports">
<AdminReports />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/system-log"
element={
<AdminErrorBoundary section="System Log">
<AdminSystemLog />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/users"
element={
<AdminErrorBoundary section="User Management">
<AdminUsers />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/blog"
element={
<AdminErrorBoundary section="Blog Management">
<AdminBlog />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/settings"
element={
<AdminErrorBoundary section="Settings">
<AdminSettings />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/contact"
element={
<AdminErrorBoundary section="Contact Management">
<AdminContact />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/email-settings"
element={
<AdminErrorBoundary section="Email Settings">
<AdminEmailSettings />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/error-monitoring"
element={
<AdminErrorBoundary section="Error Monitoring">
<ErrorMonitoring />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/error-lookup"
element={
<AdminErrorBoundary section="Error Lookup">
<ErrorLookup />
</AdminErrorBoundary>
}
/>
{/* Utility routes - lazy loaded */}
<Route path="/force-logout" element={<ForceLogout />} />
{/* Temporary test route - DELETE AFTER TESTING */}
<Route path="/test-error-logging" element={<TestErrorLogging />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</RouteErrorBoundary>
</Suspense>
</div>
<Footer />
</div>
</div>
</ResilienceProvider>
</TooltipProvider>
);
}
const App = (): React.JSX.Element => {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthModalProvider>
<MFAStepUpProvider>
<APIConnectivityProvider>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</APIConnectivityProvider>
</MFAStepUpProvider>
</AuthModalProvider>
</AuthProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
<AnalyticsWrapper />
</QueryClientProvider>
);
};
export default App;

View File

@@ -0,0 +1,126 @@
import { ReactNode, useCallback } from 'react';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationStats } from '@/hooks/useModerationStats';
interface AdminPageLayoutProps {
/** Page title */
title: string;
/** Page description */
description: string;
/** Main content to render when authorized */
children: ReactNode;
/** Optional refresh handler */
onRefresh?: () => void;
/** Whether to require MFA (default: true) */
requireMFA?: boolean;
/** Number of skeleton items to show while loading */
skeletonCount?: number;
/** Whether to show refresh controls */
showRefreshControls?: boolean;
}
/**
* Reusable admin page layout with auth guards and common UI
*
* Handles:
* - Authentication & authorization checks
* - MFA enforcement
* - Loading states
* - Refresh controls and stats
* - Consistent header layout
*
* @example
* ```tsx
* <AdminPageLayout
* title="User Management"
* description="Manage user profiles and roles"
* onRefresh={handleRefresh}
* >
* <UserManagement />
* </AdminPageLayout>
* ```
*/
export function AdminPageLayout({
title,
description,
children,
onRefresh,
requireMFA = true,
skeletonCount = 5,
showRefreshControls = true,
}: AdminPageLayoutProps) {
const { isLoading, isAuthorized, needsMFA } = useAdminGuard(requireMFA);
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode() as 'auto' | 'manual';
const pollInterval = getAdminPanelPollInterval() as number;
const { lastUpdated } = useModerationStats({
enabled: isAuthorized && showRefreshControls,
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
const handleRefreshClick = useCallback(() => {
onRefresh?.();
}, [onRefresh]);
// Loading state
if (isLoading) {
return (
<AdminLayout
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
refreshMode={showRefreshControls ? (refreshMode as 'auto' | 'manual') : undefined}
pollInterval={showRefreshControls ? pollInterval : undefined}
lastUpdated={showRefreshControls ? (lastUpdated as Date) : undefined}
>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{description}</p>
</div>
<QueueSkeleton count={skeletonCount} />
</div>
</AdminLayout>
);
}
// Not authorized
if (!isAuthorized) {
return null;
}
// Main content
return (
<AdminLayout
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
refreshMode={showRefreshControls ? (refreshMode as 'auto' | 'manual') : undefined}
pollInterval={showRefreshControls ? pollInterval : undefined}
lastUpdated={showRefreshControls ? (lastUpdated as Date) : undefined}
>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{description}</p>
</div>
{children}
</div>
</MFAGuard>
</AdminLayout>
);
}

View File

@@ -0,0 +1,372 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertTriangle, Trash2, Shield, CheckCircle2 } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { MFAChallenge } from '@/components/auth/MFAChallenge';
import { toast } from '@/hooks/use-toast';
import type { UserRole } from '@/hooks/useUserRole';
import { handleError } from '@/lib/errorHandler';
interface AdminUserDeletionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetUser: {
userId: string;
username: string;
email: string;
displayName?: string;
roles: UserRole[];
};
onDeletionComplete: () => void;
}
type DeletionStep = 'warning' | 'aal2_verification' | 'final_confirm' | 'deleting' | 'complete';
export function AdminUserDeletionDialog({
open,
onOpenChange,
targetUser,
onDeletionComplete
}: AdminUserDeletionDialogProps) {
const { session } = useAuth();
const [step, setStep] = useState<DeletionStep>('warning');
const [confirmationText, setConfirmationText] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState<string | null>(null);
const [factorId, setFactorId] = useState<string | null>(null);
// Reset state when dialog opens/closes
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setStep('warning');
setConfirmationText('');
setAcknowledged(false);
setError(null);
setFactorId(null);
}
onOpenChange(isOpen);
};
// Step 1: Show warning and proceed
const handleContinueFromWarning = async () => {
setError(null);
// Check if user needs AAL2 verification
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
if (hasMFAEnrolled) {
// Check current AAL from JWT
if (session) {
const jwt = session.access_token;
const payload = JSON.parse(atob(jwt.split('.')[1]));
const currentAal = payload.aal || 'aal1';
if (currentAal !== 'aal2') {
// Need to verify MFA
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
if (verifiedFactor) {
setFactorId(verifiedFactor.id);
setStep('aal2_verification');
return;
}
}
}
}
// If no MFA or already at AAL2, go directly to final confirmation
setStep('final_confirm');
};
// Step 2: Handle successful AAL2 verification
const handleAAL2Success = () => {
setStep('final_confirm');
};
// Step 3: Perform deletion
const handleDelete = async () => {
setError(null);
setStep('deleting');
try {
const { data, error: functionError } = await supabase.functions.invoke('admin-delete-user', {
body: { targetUserId: targetUser.userId }
});
if (functionError) {
throw functionError;
}
if (!data.success) {
if (data.errorCode === 'aal2_required') {
// Session degraded during deletion, restart AAL2 flow
setError('Your session requires re-verification. Please verify again.');
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
if (verifiedFactor) {
setFactorId(verifiedFactor.id);
setStep('aal2_verification');
} else {
setStep('warning');
}
return;
}
throw new Error(data.error || 'Failed to delete user');
}
// Success
setStep('complete');
setTimeout(() => {
toast({
title: 'User Deleted',
description: `${targetUser.username} has been permanently deleted.`,
});
onDeletionComplete();
handleOpenChange(false);
}, 2000);
} catch (err) {
handleError(err, {
action: 'Delete User',
metadata: { targetUserId: targetUser.userId }
});
setError(err instanceof Error ? err.message : 'Failed to delete user');
setStep('final_confirm');
}
};
const isDeleteEnabled = confirmationText === 'DELETE' && acknowledged;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
onInteractOutside={(e) => step === 'deleting' && e.preventDefault()}
>
{/* Step 1: Warning */}
{step === 'warning' && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<AlertTriangle className="h-6 w-6 text-destructive" />
<DialogTitle className="text-destructive">Delete User Account</DialogTitle>
</div>
<DialogDescription className="text-center">
You are about to permanently delete this user's account
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* User details */}
<div className="p-4 border rounded-lg bg-muted/50">
<div className="space-y-2">
<div>
<span className="text-sm font-medium">Username:</span>
<span className="ml-2 text-sm">{targetUser.username}</span>
</div>
<div>
<span className="text-sm font-medium">Email:</span>
<span className="ml-2 text-sm">{targetUser.email}</span>
</div>
{targetUser.displayName && (
<div>
<span className="text-sm font-medium">Display Name:</span>
<span className="ml-2 text-sm">{targetUser.displayName}</span>
</div>
)}
<div>
<span className="text-sm font-medium">Roles:</span>
<span className="ml-2 text-sm">{targetUser.roles.join(', ') || 'None'}</span>
</div>
</div>
</div>
{/* Critical warning */}
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="font-semibold">
This action is IMMEDIATE and PERMANENT. It cannot be undone.
</AlertDescription>
</Alert>
{/* What will be deleted */}
<div>
<h4 className="font-semibold text-sm mb-2 text-destructive">Will be deleted:</h4>
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
<li>User profile and personal information</li>
<li>All reviews and ratings</li>
<li>Account preferences and settings</li>
<li>Authentication credentials</li>
</ul>
</div>
{/* What will be preserved */}
<div>
<h4 className="font-semibold text-sm mb-2">Will be preserved (as anonymous):</h4>
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
<li>Content submissions (parks, rides, etc.)</li>
<li>Uploaded photos</li>
</ul>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleContinueFromWarning}>
Continue
</Button>
</div>
</div>
</>
)}
{/* Step 2: AAL2 Verification */}
{step === 'aal2_verification' && factorId && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Multi-Factor Authentication Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
This is a critical action that requires additional verification
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<MFAChallenge
factorId={factorId}
onSuccess={handleAAL2Success}
onCancel={() => {
setStep('warning');
setError(null);
}}
/>
</>
)}
{/* Step 3: Final Confirmation */}
{step === 'final_confirm' && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Trash2 className="h-6 w-6 text-destructive" />
<DialogTitle className="text-destructive">Final Confirmation</DialogTitle>
</div>
<DialogDescription className="text-center">
Type DELETE to confirm permanent deletion
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-1">Last chance to cancel!</div>
<div className="text-sm">
Deleting {targetUser.username} will immediately and permanently remove their account.
</div>
</AlertDescription>
</Alert>
<div className="space-y-2">
<label className="text-sm font-medium">
Type <span className="font-mono font-bold text-destructive">DELETE</span> to confirm:
</label>
<Input
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
placeholder="Type DELETE"
className="font-mono"
autoComplete="off"
/>
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="acknowledge"
checked={acknowledged}
onCheckedChange={(checked) => setAcknowledged(checked as boolean)}
/>
<label
htmlFor="acknowledge"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I understand this action cannot be undone and will permanently delete this user's account
</label>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isDeleteEnabled}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete User Permanently
</Button>
</div>
</div>
</>
)}
{/* Step 4: Deleting */}
{step === 'deleting' && (
<div className="py-8 text-center space-y-4">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
<div>
<h3 className="font-semibold mb-1">Deleting User...</h3>
<p className="text-sm text-muted-foreground">
This may take a moment. Please do not close this dialog.
</p>
</div>
</div>
)}
{/* Step 5: Complete */}
{step === 'complete' && (
<div className="py-8 text-center space-y-4">
<div className="flex justify-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
</div>
<div>
<h3 className="font-semibold mb-1">User Deleted Successfully</h3>
<p className="text-sm text-muted-foreground">
{targetUser.username} has been permanently removed.
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { format } from 'date-fns';
import { XCircle, Clock, User, FileText, AlertTriangle } from 'lucide-react';
import { Link } from 'react-router-dom';
interface ApprovalFailure {
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;
success: boolean;
moderator?: {
username: string;
avatar_url: string | null;
};
submission?: {
submission_type: string;
user_id: string;
};
}
interface ApprovalFailureModalProps {
failure: ApprovalFailure | null;
onClose: () => void;
}
export function ApprovalFailureModal({ failure, onClose }: ApprovalFailureModalProps) {
if (!failure) return null;
return (
<Dialog open={!!failure} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
Approval Failure Details
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="error">Error Details</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Timestamp</div>
<div className="font-medium">
{format(new Date(failure.created_at), 'PPpp')}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Duration</div>
<div className="font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
{failure.duration_ms != null ? `${failure.duration_ms}ms` : 'N/A'}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Submission Type</div>
<Badge variant="outline">
{failure.submission?.submission_type || 'Unknown'}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Items Count</div>
<div className="font-medium">{failure.items_count}</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Moderator</div>
<div className="font-medium flex items-center gap-2">
<User className="w-4 h-4" />
{failure.moderator?.username || 'Unknown'}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Submission ID</div>
<Link
to={`/admin/moderation?submission=${failure.submission_id}`}
className="font-mono text-sm text-primary hover:underline flex items-center gap-2"
>
<FileText className="w-4 h-4" />
{failure.submission_id}
</Link>
</div>
{failure.rollback_triggered && (
<div className="flex items-center gap-2 p-3 bg-warning/10 text-warning rounded-md">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">
Rollback was triggered for this approval
</span>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="error" className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div>
<div className="text-sm text-muted-foreground mb-2">Error Message</div>
<div className="p-4 bg-destructive/10 text-destructive rounded-md font-mono text-sm">
{failure.error_message || 'No error message available'}
</div>
</div>
{failure.request_id && (
<div>
<div className="text-sm text-muted-foreground mb-2">Request ID</div>
<div className="p-3 bg-muted rounded-md font-mono text-sm">
{failure.request_id}
</div>
</div>
)}
<div className="mt-4 p-4 bg-muted rounded-md">
<div className="text-sm font-medium mb-2">Troubleshooting Tips</div>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Check if the submission still exists in the database</li>
<li>Verify that all foreign key references are valid</li>
<li>Review the edge function logs for detailed stack traces</li>
<li>Check for concurrent modification conflicts</li>
<li>Verify network connectivity and database availability</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="metadata" className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Failure ID</div>
<div className="font-mono text-sm">{failure.id}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Success Status</div>
<Badge variant="destructive">
{failure.success ? 'Success' : 'Failed'}
</Badge>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Moderator ID</div>
<div className="font-mono text-sm">{failure.moderator_id}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Submitter ID</div>
<div className="font-mono text-sm">{failure.submitter_id}</div>
</div>
{failure.request_id && (
<div>
<div className="text-sm text-muted-foreground mb-1">Request ID</div>
<div className="font-mono text-sm break-all">{failure.request_id}</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground mb-1">Rollback Triggered</div>
<Badge variant={failure.rollback_triggered ? 'destructive' : 'secondary'}>
{failure.rollback_triggered ? 'Yes' : 'No'}
</Badge>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from 'react';
import { Ban, UserCheck } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
const BAN_REASONS = [
{ value: 'spam', label: 'Spam or advertising' },
{ value: 'harassment', label: 'Harassment or bullying' },
{ value: 'inappropriate_content', label: 'Inappropriate content' },
{ value: 'violation_tos', label: 'Terms of Service violation' },
{ value: 'abuse', label: 'Abuse of platform features' },
{ value: 'fake_info', label: 'Posting false information' },
{ value: 'copyright', label: 'Copyright infringement' },
{ value: 'multiple_accounts', label: 'Multiple account abuse' },
{ value: 'other', label: 'Other (specify below)' }
] as const;
const BAN_DURATIONS = [
{ value: '1', label: '1 Day', days: 1 },
{ value: '7', label: '7 Days (1 Week)', days: 7 },
{ value: '30', label: '30 Days (1 Month)', days: 30 },
{ value: '90', label: '90 Days (3 Months)', days: 90 },
{ value: 'permanent', label: 'Permanent', days: null }
] as const;
const banFormSchema = z.object({
reason_type: z.enum([
'spam',
'harassment',
'inappropriate_content',
'violation_tos',
'abuse',
'fake_info',
'copyright',
'multiple_accounts',
'other'
]),
custom_reason: z.string().max(500).optional(),
duration: z.enum(['1', '7', '30', '90', 'permanent'])
}).refine(
(data) => data.reason_type !== 'other' || (data.custom_reason && data.custom_reason.trim().length > 0),
{
message: "Please provide a custom reason",
path: ["custom_reason"]
}
);
type BanFormValues = z.infer<typeof banFormSchema>;
interface BanUserDialogProps {
profile: {
user_id: string;
username: string;
banned: boolean;
};
onBanComplete: () => void;
onBanUser: (userId: string, ban: boolean, reason?: string, expiresAt?: Date | null) => Promise<void>;
disabled?: boolean;
}
export function BanUserDialog({ profile, onBanComplete, onBanUser, disabled }: BanUserDialogProps) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<BanFormValues>({
resolver: zodResolver(banFormSchema),
defaultValues: {
reason_type: 'violation_tos',
custom_reason: '',
duration: '7'
}
});
const watchReasonType = form.watch('reason_type');
const watchDuration = form.watch('duration');
const onSubmit = async (values: BanFormValues) => {
setIsSubmitting(true);
try {
// Construct the ban reason
let banReason: string;
if (values.reason_type === 'other' && values.custom_reason) {
banReason = values.custom_reason.trim();
} else {
const selectedReason = BAN_REASONS.find(r => r.value === values.reason_type);
banReason = selectedReason?.label || 'Policy violation';
}
// Calculate expiration date
let expiresAt: Date | null = null;
if (values.duration !== 'permanent') {
const durationConfig = BAN_DURATIONS.find(d => d.value === values.duration);
if (durationConfig?.days) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + durationConfig.days);
}
}
await onBanUser(profile.user_id, true, banReason, expiresAt);
setOpen(false);
form.reset();
onBanComplete();
} catch (error) {
// Error handling is done by the parent component
} finally {
setIsSubmitting(false);
}
};
const handleUnban = async () => {
setIsSubmitting(true);
try {
await onBanUser(profile.user_id, false);
setOpen(false);
onBanComplete();
} catch (error) {
// Error handling is done by the parent component
} finally {
setIsSubmitting(false);
}
};
// For unbanning, use simpler dialog
if (profile.banned) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
<UserCheck className="w-4 h-4 mr-2" />
Unban
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Unban User</DialogTitle>
<DialogDescription>
Are you sure you want to unban @{profile.username}? They will be able to access the application again.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleUnban} disabled={isSubmitting}>
{isSubmitting ? 'Unbanning...' : 'Unban User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// For banning, use detailed form
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={disabled}>
<Ban className="w-4 h-4 mr-2" />
Ban
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
<DialogDescription>
Ban @{profile.username} from accessing the application. You must provide a reason and duration.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="reason_type"
render={({ field }) => (
<FormItem>
<FormLabel>Ban Reason</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
</FormControl>
<SelectContent>
{BAN_REASONS.map((reason) => (
<SelectItem key={reason.value} value={reason.value}>
{reason.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose the primary reason for this ban
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchReasonType === 'other' && (
<FormField
control={form.control}
name="custom_reason"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Reason</FormLabel>
<FormControl>
<Textarea
placeholder="Provide a detailed reason for the ban..."
className="min-h-[100px] resize-none"
maxLength={500}
{...field}
/>
</FormControl>
<FormDescription>
{field.value?.length || 0}/500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Ban Duration</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{BAN_DURATIONS.map((duration) => (
<SelectItem key={duration.value} value={duration.value}>
{duration.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
How long should this ban last?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Alert>
<AlertDescription>
<strong>User will see:</strong> Your account has been suspended. Reason:{' '}
{watchReasonType === 'other' && form.getValues('custom_reason')
? form.getValues('custom_reason')
: BAN_REASONS.find(r => r.value === watchReasonType)?.label || 'Policy violation'}
.{' '}
{watchDuration === 'permanent'
? 'This is a permanent ban.'
: `This ban will expire in ${BAN_DURATIONS.find(d => d.value === watchDuration)?.label.toLowerCase()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={isSubmitting}>
{isSubmitting ? 'Banning...' : 'Ban User'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,306 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas } from '@/lib/entityValidationSchemas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Ruler, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type DesignerFormData = z.infer<typeof entitySchemas.designer>;
interface DesignerFormProps {
onSubmit: (data: DesignerFormData) => void;
onCancel: () => void;
initialData?: Partial<DesignerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps): React.JSX.Element {
const { isModerator } = useUserRole();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm({
resolver: zodResolver(entitySchemas.designer),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'designer' as const,
description: initialData?.description || '',
person_type: initialData?.person_type || ('company' as const),
website_url: initialData?.website_url || '',
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
headquarters_location: initialData?.headquarters_location || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Ruler className="w-5 h-5" />
{initialData ? 'Edit Designer' : 'Create New Designer'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(async (data) => {
if (!user) {
toast.error('You must be logged in to submit');
return;
}
setIsSubmitting(true);
try {
const formData = {
...data,
company_type: 'designer' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: undefined,
banner_image_id: undefined,
banner_image_url: undefined,
card_image_id: undefined,
card_image_url: undefined,
};
await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue
if (!initialData?.id) {
toast.success('Designer submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Designer' : 'Create Designer',
metadata: { companyName: data.name }
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
})} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter designer name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the designer..."
rows={3}
/>
</div>
{/* Person Type */}
<div className="space-y-2">
<Label>Entity Type *</Label>
<RadioGroup
value={watch('person_type')}
onValueChange={(value) => setValue('person_type', value as 'company' | 'individual' | 'firm' | 'organization')}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="company" id="company" />
<Label htmlFor="company" className="cursor-pointer">Company</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="firm" id="firm" />
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="organization" />
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
</div>
</RadioGroup>
</div>
{/* Additional Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="headquarters_location">Headquarters Location</Label>
<HeadquartersLocationInput
value={watch('headquarters_location') || ''}
onChange={(value) => setValue('headquarters_location', value)}
/>
<p className="text-xs text-muted-foreground">
Search OpenStreetMap for accurate location data, or manually enter location name.
</p>
</div>
</div>
{/* Website */}
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://example.com"
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via company website', 'Founded date approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="designer"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{initialData?.id ? 'Update Designer' : 'Create Designer'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,177 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { AlertCircle, TrendingUp, Users, Zap, CheckCircle, XCircle } from 'lucide-react';
interface ErrorSummary {
error_type: string | null;
occurrence_count: number | null;
affected_users: number | null;
avg_duration_ms: number | null;
}
interface ApprovalMetric {
id: string;
success: boolean;
duration_ms: number | null;
created_at: string | null;
}
interface ErrorAnalyticsProps {
errorSummary: ErrorSummary[] | undefined;
approvalMetrics: ApprovalMetric[] | undefined;
}
export function ErrorAnalytics({ errorSummary, approvalMetrics }: ErrorAnalyticsProps) {
// Calculate error metrics
const totalErrors = errorSummary?.reduce((sum, item) => sum + (item.occurrence_count || 0), 0) || 0;
const totalAffectedUsers = errorSummary?.reduce((sum, item) => sum + (item.affected_users || 0), 0) || 0;
const avgErrorDuration = errorSummary?.length
? errorSummary.reduce((sum, item) => sum + (item.avg_duration_ms || 0), 0) / errorSummary.length
: 0;
const topErrors = errorSummary?.slice(0, 5) || [];
// Calculate approval metrics
const totalApprovals = approvalMetrics?.length || 0;
const failedApprovals = approvalMetrics?.filter(m => !m.success).length || 0;
const successRate = totalApprovals > 0 ? ((totalApprovals - failedApprovals) / totalApprovals) * 100 : 0;
const avgApprovalDuration = approvalMetrics?.length
? approvalMetrics.reduce((sum, m) => sum + (m.duration_ms || 0), 0) / approvalMetrics.length
: 0;
// Show message if no data available
if ((!errorSummary || errorSummary.length === 0) && (!approvalMetrics || approvalMetrics.length === 0)) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">No analytics data available</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Error Metrics */}
{errorSummary && errorSummary.length > 0 && (
<>
<div>
<h3 className="text-lg font-semibold mb-3">Error Metrics</h3>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Errors</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalErrors}</div>
<p className="text-xs text-muted-foreground">Last 30 days</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Types</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{errorSummary.length}</div>
<p className="text-xs text-muted-foreground">Unique error types</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Affected Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAffectedUsers}</div>
<p className="text-xs text-muted-foreground">Users impacted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Duration</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Math.round(avgErrorDuration)}ms</div>
<p className="text-xs text-muted-foreground">Before error occurs</p>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Top 5 Errors</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={topErrors}>
<XAxis dataKey="error_type" />
<YAxis />
<Tooltip />
<Bar dataKey="occurrence_count" fill="hsl(var(--destructive))" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</>
)}
{/* Approval Metrics */}
{approvalMetrics && approvalMetrics.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Approval Metrics</h3>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Approvals</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalApprovals}</div>
<p className="text-xs text-muted-foreground">Last 24 hours</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Failures</CardTitle>
<XCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{failedApprovals}</div>
<p className="text-xs text-muted-foreground">Failed approvals</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{successRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground">Overall success rate</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Duration</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Math.round(avgApprovalDuration)}ms</div>
<p className="text-xs text-muted-foreground">Approval time</p>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Copy, ExternalLink } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabaseClient';
interface Breadcrumb {
timestamp: string;
category: string;
message: string;
level?: string;
sequence_order?: number;
}
interface ErrorDetails {
request_id: string;
created_at: string;
error_type: string;
error_message: string;
error_stack?: string;
endpoint: string;
method: string;
status_code: number;
duration_ms: number;
user_id?: string;
request_breadcrumbs?: Breadcrumb[];
user_agent?: string;
client_version?: string;
timezone?: string;
referrer?: string;
ip_address_hash?: string;
}
interface ErrorDetailsModalProps {
error: ErrorDetails;
onClose: () => void;
}
export function ErrorDetailsModal({ error, onClose }: ErrorDetailsModalProps) {
// Use breadcrumbs from error object if already fetched, otherwise they'll be empty
const breadcrumbs = error.request_breadcrumbs || [];
const copyErrorId = () => {
navigator.clipboard.writeText(error.request_id);
toast.success('Error ID copied to clipboard');
};
const copyErrorReport = () => {
const report = `
Error Report
============
Request ID: ${error.request_id}
Timestamp: ${format(new Date(error.created_at), 'PPpp')}
Type: ${error.error_type}
Endpoint: ${error.endpoint}
Method: ${error.method}
Status: ${error.status_code}${error.duration_ms != null ? `\nDuration: ${error.duration_ms}ms` : ''}
Error Message:
${error.error_message}
${error.error_stack ? `Stack Trace:\n${error.error_stack}` : ''}
`.trim();
navigator.clipboard.writeText(report);
toast.success('Error report copied to clipboard');
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Error Details
<Badge variant="destructive">{error.error_type}</Badge>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="stack">Stack Trace</TabsTrigger>
<TabsTrigger value="breadcrumbs">Breadcrumbs</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Request ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded">
{error.request_id}
</code>
<Button size="sm" variant="ghost" onClick={copyErrorId}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div>
<label className="text-sm font-medium">Timestamp</label>
<p className="text-sm">{format(new Date(error.created_at), 'PPpp')}</p>
</div>
<div>
<label className="text-sm font-medium">Endpoint</label>
<p className="text-sm font-mono">{error.endpoint}</p>
</div>
<div>
<label className="text-sm font-medium">Method</label>
<Badge variant="outline">{error.method}</Badge>
</div>
<div>
<label className="text-sm font-medium">Status Code</label>
<p className="text-sm">{error.status_code}</p>
</div>
{error.duration_ms != null && (
<div>
<label className="text-sm font-medium">Duration</label>
<p className="text-sm">{error.duration_ms}ms</p>
</div>
)}
{error.user_id && (
<div>
<label className="text-sm font-medium">User ID</label>
<a
href={`/admin/users?search=${error.user_id}`}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
{error.user_id.slice(0, 8)}...
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
<div>
<label className="text-sm font-medium">Error Message</label>
<div className="bg-muted p-4 rounded-lg mt-2">
<p className="text-sm font-mono">{error.error_message}</p>
</div>
</div>
</TabsContent>
<TabsContent value="stack">
{error.error_stack ? (
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-xs">
{error.error_stack}
</pre>
) : (
<p className="text-muted-foreground">No stack trace available</p>
)}
</TabsContent>
<TabsContent value="breadcrumbs">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="space-y-2">
{breadcrumbs
.sort((a, b) => (a.sequence_order || 0) - (b.sequence_order || 0))
.map((crumb, index) => (
<div key={index} className="border-l-2 border-primary pl-4 py-2">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
{crumb.category}
</Badge>
<Badge variant={crumb.level === 'error' ? 'destructive' : 'secondary'} className="text-xs">
{crumb.level || 'info'}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(crumb.timestamp), 'HH:mm:ss.SSS')}
</span>
</div>
<p className="text-sm">{crumb.message}</p>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No breadcrumbs recorded</p>
)}
</TabsContent>
<TabsContent value="environment">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{error.user_agent && (
<div>
<label className="text-sm font-medium">User Agent</label>
<p className="text-xs font-mono break-all">{error.user_agent}</p>
</div>
)}
{error.client_version && (
<div>
<label className="text-sm font-medium">Client Version</label>
<p className="text-sm">{error.client_version}</p>
</div>
)}
{error.timezone && (
<div>
<label className="text-sm font-medium">Timezone</label>
<p className="text-sm">{error.timezone}</p>
</div>
)}
{error.referrer && (
<div>
<label className="text-sm font-medium">Referrer</label>
<p className="text-xs font-mono break-all">{error.referrer}</p>
</div>
)}
{error.ip_address_hash && (
<div>
<label className="text-sm font-medium">IP Hash</label>
<p className="text-xs font-mono">{error.ip_address_hash}</p>
</div>
)}
</div>
{!error.user_agent && !error.client_version && !error.timezone && !error.referrer && !error.ip_address_hash && (
<p className="text-muted-foreground">No environment data available</p>
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={copyErrorReport}>
<Copy className="w-4 h-4 mr-2" />
Copy Report
</Button>
<Button onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Search, Edit, MapPin, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface LocationResult {
place_id: number;
display_name: string;
address?: {
city?: string;
town?: string;
village?: string;
state?: string;
country?: string;
};
}
interface HeadquartersLocationInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function HeadquartersLocationInput({
value,
onChange,
disabled = false,
className
}: HeadquartersLocationInputProps): React.JSX.Element {
const [mode, setMode] = useState<'search' | 'manual'>('search');
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
// Debounced search effect
useEffect(() => {
if (!searchQuery || searchQuery.length < 2) {
setResults([]);
setShowResults(false);
return;
}
const timeoutId = setTimeout(async (): Promise<void> => {
setIsSearching(true);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(
searchQuery
)}&limit=5&addressdetails=1`,
{
headers: {
'User-Agent': 'ThemeParkArchive/1.0'
}
}
);
if (response.ok) {
const data = await response.json() as LocationResult[];
setResults(data);
setShowResults(true);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Search headquarters locations',
metadata: { query: searchQuery }
});
} finally {
setIsSearching(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
const formatLocation = (result: LocationResult): string => {
const { city, town, village, state, country } = result.address || {};
const cityName = city || town || village;
if (cityName && state && country) {
return `${cityName}, ${state}, ${country}`;
} else if (cityName && country) {
return `${cityName}, ${country}`;
} else if (country) {
return country;
}
return result.display_name;
};
const handleSelectLocation = (result: LocationResult): void => {
const formatted = formatLocation(result);
onChange(formatted);
setSearchQuery('');
setShowResults(false);
setResults([]);
};
const handleClear = (): void => {
onChange('');
setSearchQuery('');
setResults([]);
setShowResults(false);
};
return (
<div className={cn('space-y-2', className)}>
<Tabs value={mode} onValueChange={(val) => setMode(val as 'search' | 'manual')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="search" disabled={disabled}>
<Search className="w-4 h-4 mr-2" />
Search Location
</TabsTrigger>
<TabsTrigger value="manual" disabled={disabled}>
<Edit className="w-4 h-4 mr-2" />
Manual Entry
</TabsTrigger>
</TabsList>
<TabsContent value="search" className="space-y-2 mt-4">
<div className="relative">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for location (e.g., Munich, Germany)..."
disabled={disabled}
className="pr-10"
/>
{isSearching && (
<Loader2 className="w-4 h-4 absolute right-3 top-3 animate-spin text-muted-foreground" />
)}
</div>
{showResults && results.length > 0 && (
<div className="border rounded-md bg-card max-h-48 overflow-y-auto">
{results.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => handleSelectLocation(result)}
className="w-full text-left px-3 py-2 hover:bg-accent hover:text-accent-foreground text-sm flex items-start gap-2 transition-colors"
disabled={disabled}
>
<MapPin className="w-4 h-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
<span className="flex-1">{formatLocation(result)}</span>
</button>
))}
</div>
)}
{showResults && results.length === 0 && !isSearching && (
<p className="text-sm text-muted-foreground px-3 py-2">
No locations found. Try a different search term.
</p>
)}
{value && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<MapPin className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm flex-1">{value}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
disabled={disabled}
className="h-6 px-2"
>
<X className="w-3 h-3" />
</Button>
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter location manually..."
disabled={disabled}
/>
<p className="text-xs text-muted-foreground mt-2">
Enter any location text. For better data quality, use Search mode.
</p>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,284 @@
/**
* Integration Test Runner Component
*
* Superuser-only UI for running comprehensive integration tests.
* Requires AAL2 if MFA is enrolled.
*/
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult } from '@/lib/integrationTests';
import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
export function IntegrationTestRunner() {
const superuserGuard = useSuperuserGuard();
const [selectedSuites, setSelectedSuites] = useState<string[]>(allTestSuites.map(s => s.id));
const [isRunning, setIsRunning] = useState(false);
const [results, setResults] = useState<TestResult[]>([]);
const [runner] = useState(() => new TestRunner((result) => {
setResults(prev => {
const existing = prev.findIndex(r => r.id === result.id);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = result;
return updated;
}
return [...prev, result];
});
}));
const toggleSuite = useCallback((suiteId: string) => {
setSelectedSuites(prev =>
prev.includes(suiteId)
? prev.filter(id => id !== suiteId)
: [...prev, suiteId]
);
}, []);
const runTests = useCallback(async () => {
const suitesToRun = allTestSuites.filter(s => selectedSuites.includes(s.id));
if (suitesToRun.length === 0) {
toast.error('Please select at least one test suite');
return;
}
setIsRunning(true);
setResults([]);
runner.reset();
toast.info(`Running ${suitesToRun.length} test suite(s)...`);
try {
await runner.runAllSuites(suitesToRun);
const summary = runner.getSummary();
if (summary.failed > 0) {
toast.error(`Tests completed with ${summary.failed} failure(s)`);
} else {
toast.success(`All ${summary.passed} tests passed!`);
}
} catch (error: unknown) {
handleError(error, {
action: 'Run integration tests',
metadata: { suitesCount: suitesToRun.length }
});
toast.error('Test run failed');
} finally {
setIsRunning(false);
}
}, [selectedSuites, runner]);
const stopTests = useCallback(() => {
runner.stop();
setIsRunning(false);
toast.info('Test run stopped');
}, [runner]);
const exportResults = useCallback(() => {
const summary = runner.getSummary();
const exportData = {
timestamp: new Date().toISOString(),
summary,
results: runner.getResults()
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `integration-tests-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Test results exported');
}, [runner]);
// Guard is handled by the route/page, no loading state needed here
const summary = runner.getSummary();
const totalTests = allTestSuites
.filter(s => selectedSuites.includes(s.id))
.reduce((sum, s) => sum + s.tests.length, 0);
const progress = totalTests > 0 ? (results.length / totalTests) * 100 : 0;
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
🧪 Integration Test Runner
</CardTitle>
<CardDescription>
Superuser-only comprehensive testing system. Tests run against real database functions and edge functions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Suite Selection */}
<div className="space-y-3">
<h3 className="font-medium">Select Test Suites:</h3>
<div className="space-y-2">
{allTestSuites.map(suite => (
<div key={suite.id} className="flex items-start space-x-3">
<Checkbox
id={suite.id}
checked={selectedSuites.includes(suite.id)}
onCheckedChange={() => toggleSuite(suite.id)}
disabled={isRunning}
/>
<div className="space-y-1 flex-1">
<label
htmlFor={suite.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{suite.name} ({suite.tests.length} tests)
</label>
<p className="text-sm text-muted-foreground">
{suite.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* Controls */}
<div className="flex gap-2">
<Button onClick={runTests} loading={isRunning} loadingText="Running..." disabled={selectedSuites.length === 0}>
<Play className="w-4 h-4 mr-2" />
Run Selected
</Button>
{isRunning && (
<Button onClick={stopTests} variant="destructive">
<Square className="w-4 h-4 mr-2" />
Stop
</Button>
)}
{results.length > 0 && !isRunning && (
<Button onClick={exportResults} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Results
</Button>
)}
</div>
{/* Progress */}
{results.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Progress: {results.length}/{totalTests} tests</span>
<span>{progress.toFixed(0)}%</span>
</div>
<Progress value={progress} />
</div>
)}
{/* Summary */}
{results.length > 0 && (
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span>{summary.passed} passed</span>
</div>
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-destructive" />
<span>{summary.failed} failed</span>
</div>
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-muted-foreground" />
<span>{summary.skipped} skipped</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{(summary.totalDuration / 1000).toFixed(2)}s</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Results */}
{results.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Test Results</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-2">
{results.map(result => (
<Collapsible key={result.id}>
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="pt-0.5">
{result.status === 'pass' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{result.status === 'fail' && <XCircle className="w-4 h-4 text-destructive" />}
{result.status === 'skip' && <SkipForward className="w-4 h-4 text-muted-foreground" />}
{result.status === 'running' && <Clock className="w-4 h-4 text-blue-500 animate-pulse" />}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-sm font-medium">{result.name}</p>
<p className="text-xs text-muted-foreground">{result.suite}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{result.duration}ms
</Badge>
{(result.error || result.details) && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<ChevronDown className="h-3 w-3" />
</Button>
</CollapsibleTrigger>
)}
</div>
</div>
{result.error && (
<p className="text-sm text-destructive">{result.error}</p>
)}
</div>
</div>
{(result.error || result.details) && (
<CollapsibleContent>
<div className="ml-7 mt-2 p-3 rounded-lg bg-muted/50 space-y-2">
{result.error && result.stack && (
<div>
<p className="text-xs font-medium mb-1">Stack Trace:</p>
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
{result.stack}
</pre>
</div>
)}
{result.details && (
<div>
<p className="text-xs font-medium mb-1">Details:</p>
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
{JSON.stringify(result.details, null, 2)}
</pre>
</div>
)}
</div>
</CollapsibleContent>
)}
</Collapsible>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { supabase } from '@/lib/supabaseClient';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { MapPin, Loader2, X } from 'lucide-react';
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface LocationResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
address: {
house_number?: string;
road?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
state?: string;
province?: string;
state_district?: string;
county?: string;
region?: string;
territory?: string;
country?: string;
country_code?: string;
postcode?: string;
};
}
interface SelectedLocation {
name: string;
street_address?: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string; // Full OSM display name for reference
}
interface LocationSearchProps {
onLocationSelect: (location: SelectedLocation) => void;
initialLocationId?: string;
className?: string;
}
export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps): React.JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
const [showResults, setShowResults] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 500);
// Load initial location if editing
useEffect(() => {
if (initialLocationId) {
void loadInitialLocation(initialLocationId);
}
}, [initialLocationId]);
const loadInitialLocation = async (locationId: string): Promise<void> => {
const { data, error } = await supabase
.from('locations')
.select('id, name, street_address, city, state_province, country, postal_code, latitude, longitude, timezone')
.eq('id', locationId)
.maybeSingle();
if (data && !error) {
setSelectedLocation({
name: data.name,
street_address: data.street_address || undefined,
city: data.city || undefined,
state_province: data.state_province || undefined,
country: data.country,
postal_code: data.postal_code || undefined,
latitude: parseFloat(data.latitude?.toString() || '0'),
longitude: parseFloat(data.longitude?.toString() || '0'),
timezone: data.timezone || undefined,
display_name: data.name, // Use name as display for existing locations
});
}
};
const searchLocations = useCallback(async (query: string) => {
if (!query || query.length < 3) {
setResults([]);
setSearchError(null);
return;
}
setIsSearching(true);
setSearchError(null);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`,
{
headers: {
'User-Agent': 'ThemeParkDatabase/1.0',
},
}
);
// Check if response is OK and content-type is JSON
if (!response.ok) {
const errorMsg = `Location search failed (${response.status}). Please try again.`;
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const errorMsg = 'Invalid response from location service. Please try again.';
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const data = await response.json() as LocationResult[];
setResults(data);
setShowResults(true);
setSearchError(null);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Search locations',
metadata: { query: searchQuery }
});
setSearchError('Failed to search locations. Please check your connection.');
setResults([]);
setShowResults(false);
} finally {
setIsSearching(false);
}
}, []);
useEffect(() => {
if (debouncedSearch) {
void searchLocations(debouncedSearch);
} else {
setResults([]);
setShowResults(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
const handleSelectResult = (result: LocationResult): void => {
const latitude = parseFloat(result.lat);
const longitude = parseFloat(result.lon);
// Safely access address properties with fallback
const address = result.address || {};
// Extract street address components
const houseNumber = address.house_number || '';
const road = address.road || '';
const streetAddress = [houseNumber, road].filter(Boolean).join(' ').trim() || undefined;
// Extract city
const city = address.city || address.town || address.village || address.municipality;
// Extract state/province (try multiple fields for international support)
const state = address.state ||
address.province ||
address.state_district ||
address.county ||
address.region ||
address.territory;
const country = address.country || 'Unknown';
const postalCode = address.postcode;
// Build location name
const locationParts = [streetAddress, city, state, country].filter(Boolean);
const locationName = locationParts.join(', ');
// Build location data object (no database operations)
const locationData: SelectedLocation = {
name: locationName,
street_address: streetAddress,
city: city || undefined,
state_province: state || undefined,
country: country,
postal_code: postalCode || undefined,
latitude,
longitude,
timezone: undefined, // Will be set by server during approval if needed
display_name: result.display_name,
};
setSelectedLocation(locationData);
setSearchQuery('');
setResults([]);
setShowResults(false);
onLocationSelect(locationData);
};
const handleClear = (): void => {
setSelectedLocation(null);
setSearchQuery('');
setResults([]);
setShowResults(false);
};
return (
<div className={className}>
{!selectedLocation ? (
<div className="space-y-2">
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search for a location (city, address, landmark...)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{searchError && (
<div className="text-sm text-destructive mt-1">
{searchError}
</div>
)}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
<div className="divide-y">
{results.map((result) => (
<button
type="button"
key={result.place_id}
onClick={() => void handleSelectResult(result)}
className="w-full text-left p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.display_name}</p>
<p className="text-xs text-muted-foreground">
{result.lat}, {result.lon}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
{showResults && results.length === 0 && !isSearching && !searchError && (
<div className="text-sm text-muted-foreground mt-1">
No locations found. Try a different search term.
</div>
)}
</div>
) : (
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<MapPin className="h-5 w-5 text-primary mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium">{selectedLocation.name}</p>
<div className="text-sm text-muted-foreground space-y-1 mt-1">
{selectedLocation.street_address && <p>Street: {selectedLocation.street_address}</p>}
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
<p>Country: {selectedLocation.country}</p>
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
<p className="text-xs">
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
</p>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
<ParkLocationMap
latitude={selectedLocation.latitude}
longitude={selectedLocation.longitude}
parkName={selectedLocation.name}
className="h-48"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,310 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas } from '@/lib/entityValidationSchemas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Building2, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type ManufacturerFormData = z.infer<typeof entitySchemas.manufacturer>;
interface ManufacturerFormProps {
onSubmit: (data: ManufacturerFormData) => void;
onCancel: () => void;
initialData?: Partial<ManufacturerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps): React.JSX.Element {
const { isModerator } = useUserRole();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm({
resolver: zodResolver(entitySchemas.manufacturer),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'manufacturer' as const,
description: initialData?.description || '',
person_type: initialData?.person_type || ('company' as const),
website_url: initialData?.website_url || '',
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
founded_date: initialData?.founded_date || (initialData?.founded_year ? `${initialData.founded_year}-01-01` : undefined),
founded_date_precision: initialData?.founded_date_precision || (initialData?.founded_year ? ('year' as const) : ('day' as const)),
headquarters_location: initialData?.headquarters_location || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5" />
{initialData ? 'Edit Manufacturer' : 'Create New Manufacturer'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(async (data) => {
if (!user) {
toast.error('You must be logged in to submit');
return;
}
setIsSubmitting(true);
try {
const formData = {
...data,
company_type: 'manufacturer' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
banner_image_id: undefined,
banner_image_url: undefined,
card_image_id: undefined,
card_image_url: undefined,
};
await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue
if (!initialData?.id) {
toast.success('Manufacturer submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Manufacturer' : 'Create Manufacturer',
metadata: { companyName: data.name }
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
})} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter manufacturer name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the manufacturer..."
rows={3}
/>
</div>
{/* Person Type */}
<div className="space-y-2">
<Label>Entity Type *</Label>
<RadioGroup
value={watch('person_type')}
onValueChange={(value) => setValue('person_type', value as 'company' | 'individual' | 'firm' | 'organization')}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="company" id="company" />
<Label htmlFor="company" className="cursor-pointer">Company</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="firm" id="firm" />
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="organization" />
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
</div>
</RadioGroup>
</div>
{/* Additional Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FlexibleDateInput
value={(() => {
const dateValue = watch('founded_date');
if (!dateValue) return undefined;
return parseDateOnly(dateValue);
})()}
precision={(watch('founded_date_precision') as DatePrecision) || 'year'}
onChange={(date, precision) => {
setValue('founded_date', date ? toDateWithPrecision(date, precision) : undefined, { shouldValidate: true });
setValue('founded_date_precision', precision);
}}
label="Founded Date"
placeholder="Select founded date"
disableFuture={true}
fromYear={1800}
/>
<div className="space-y-2">
<Label htmlFor="headquarters_location">Headquarters Location</Label>
<HeadquartersLocationInput
value={watch('headquarters_location') || ''}
onChange={(value) => setValue('headquarters_location', value)}
/>
<p className="text-xs text-muted-foreground">
Search OpenStreetMap for accurate location data, or manually enter location name.
</p>
</div>
</div>
{/* Website */}
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://example.com"
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via company website', 'Founded date approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="manufacturer"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{initialData?.id ? 'Update Manufacturer' : 'Create Manufacturer'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,239 @@
import { useEffect, useState, useRef } from 'react';
import {
MDXEditor,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
linkPlugin,
linkDialogPlugin,
imagePlugin,
tablePlugin,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
toolbarPlugin,
UndoRedo,
BoldItalicUnderlineToggles,
CodeToggle,
ListsToggle,
BlockTypeSelect,
CreateLink,
InsertImage,
InsertTable,
InsertThematicBreak,
DiffSourceToggleWrapper,
type MDXEditorMethods
} from '@mdxeditor/editor';
import '@mdxeditor/editor/style.css';
import '@/styles/mdx-editor-theme.css';
import { useTheme } from '@/components/theme/ThemeProvider';
import { supabase } from '@/lib/supabaseClient';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { useAutoSave } from '@/hooks/useAutoSave';
import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { handleError } from '@/lib/errorHandler';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave?: (value: string) => Promise<void>;
autoSave?: boolean;
height?: number;
placeholder?: string;
}
export function MarkdownEditor({
value,
onChange,
onSave,
autoSave = false,
height = 600,
placeholder = 'Write your content in markdown...'
}: MarkdownEditorProps): React.JSX.Element {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const editorRef = useRef<MDXEditorMethods>(null);
// Resolve "system" theme to actual theme based on OS preference
useEffect(() => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setResolvedTheme(isDark ? 'dark' : 'light');
} else {
setResolvedTheme(theme);
}
}, [theme]);
// Listen for OS theme changes when in system mode
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent): void => {
setResolvedTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
// Auto-save integration
const { isSaving, lastSaved, error } = useAutoSave({
data: value,
onSave: onSave || (async () => {}),
debounceMs: 3000,
enabled: autoSave && !!onSave,
isValid: value.length > 0
});
useEffect(() => {
setMounted(true);
}, []);
// Prevent hydration mismatch
if (!mounted) {
return (
<div
className="border border-input rounded-lg bg-muted/50 flex items-center justify-center"
style={{ height }}
>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const getLastSavedText = (): string | null => {
if (!lastSaved) return null;
const seconds = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
if (seconds < 60) return `Saved ${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `Saved ${minutes}m ago`;
};
return (
<div className="space-y-2">
<div
className="border border-input rounded-lg overflow-hidden"
style={{ minHeight: height }}
>
<MDXEditor
ref={editorRef}
className={cn('mdxeditor', resolvedTheme === 'dark' && 'dark-theme')}
markdown={value}
onChange={onChange}
placeholder={placeholder}
contentEditableClassName="prose dark:prose-invert max-w-none p-4 min-h-[500px] mdx-content-area"
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
linkPlugin(),
linkDialogPlugin(),
imagePlugin({
imageUploadHandler: async (file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('file', file);
const { data, error } = await invokeWithTracking(
'upload-image',
formData,
undefined
);
if (error) throw error;
// Return Cloudflare CDN URL
const imageUrl = getCloudflareImageUrl((data as { id: string }).id, 'public');
if (!imageUrl) throw new Error('Failed to generate image URL');
return imageUrl;
} catch (error: unknown) {
handleError(error, {
action: 'Upload markdown image',
metadata: { fileName: file.name }
});
throw new Error(error instanceof Error ? error.message : 'Failed to upload image');
}
}
}),
tablePlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }),
codeMirrorPlugin({
codeBlockLanguages: {
js: 'JavaScript',
ts: 'TypeScript',
tsx: 'TypeScript (React)',
jsx: 'JavaScript (React)',
css: 'CSS',
html: 'HTML',
python: 'Python',
bash: 'Bash',
json: 'JSON',
sql: 'SQL'
}
}),
diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: '' }),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<CodeToggle />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertTable />
<InsertThematicBreak />
<DiffSourceToggleWrapper>
<span className="text-sm">Source</span>
</DiffSourceToggleWrapper>
</>
)
})
]}
/>
</div>
{/* Auto-save status indicator */}
{autoSave && onSave && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{isSaving && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
)}
{!isSaving && lastSaved && !error && (
<>
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span>{getLastSavedText()}</span>
</>
)}
{error && (
<>
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">Failed to save: {error}</span>
</>
)}
</div>
)}
{/* Word and character count */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>Supports markdown formatting with live preview</span>
<span>
{value.split(/\s+/).filter(Boolean).length} words · {value.length} characters
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { lazy, Suspense } from 'react';
import { EditorSkeleton } from '@/components/loading/PageSkeletons';
const MarkdownEditor = lazy(() =>
import('./MarkdownEditor').then(module => ({ default: module.MarkdownEditor }))
);
export interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave?: (value: string) => Promise<void>;
autoSave?: boolean;
height?: number;
placeholder?: string;
}
export function MarkdownEditorLazy(props: MarkdownEditorProps): React.JSX.Element {
return (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertTriangle, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { format } from 'date-fns';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface DuplicateStats {
date: string | null;
total_attempts: number | null;
duplicates_prevented: number | null;
prevention_rate: number | null;
health_status: 'healthy' | 'warning' | 'critical';
}
interface RecentDuplicate {
id: string;
user_id: string;
channel: string;
idempotency_key: string | null;
created_at: string;
profiles?: {
username: string;
display_name: string | null;
};
}
export function NotificationDebugPanel() {
const [stats, setStats] = useState<DuplicateStats[]>([]);
const [recentDuplicates, setRecentDuplicates] = useState<RecentDuplicate[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
// Load health dashboard
const { data: healthData, error: healthError } = await supabase
.from('notification_health_dashboard')
.select('*')
.limit(7);
if (healthError) throw healthError;
if (healthData) {
setStats(healthData.map(stat => ({
...stat,
health_status: stat.health_status as 'healthy' | 'warning' | 'critical'
})));
}
// Load recent prevented duplicates
const { data: duplicates, error: duplicatesError } = await supabase
.from('notification_logs')
.select(`
id,
user_id,
channel,
idempotency_key,
created_at
`)
.eq('is_duplicate', true)
.order('created_at', { ascending: false })
.limit(10);
if (duplicatesError) throw duplicatesError;
if (duplicates) {
// Fetch profiles separately
const userIds = [...new Set(duplicates.map(d => d.user_id))];
const { data: profiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
setRecentDuplicates(duplicates.map(dup => ({
...dup,
profiles: profileMap.get(dup.user_id)
})));
}
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load notification debug data'
});
} finally {
setIsLoading(false);
}
};
const getHealthBadge = (status: string) => {
switch (status) {
case 'healthy':
return (
<Badge variant="default" className="bg-green-500">
<CheckCircle className="h-3 w-3 mr-1" />
Healthy
</Badge>
);
case 'warning':
return (
<Badge variant="secondary">
<AlertTriangle className="h-3 w-3 mr-1" />
Warning
</Badge>
);
case 'critical':
return (
<Badge variant="destructive">
<AlertTriangle className="h-3 w-3 mr-1" />
Critical
</Badge>
);
default:
return <Badge>Unknown</Badge>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Notification Health Dashboard</CardTitle>
<CardDescription>Monitor duplicate prevention and notification system health</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadData} loading={isLoading} loadingText="Loading...">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{stats.length === 0 ? (
<Alert>
<AlertDescription>No notification statistics available yet</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Total Attempts</TableHead>
<TableHead className="text-right">Duplicates Prevented</TableHead>
<TableHead className="text-right">Prevention Rate</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.map((stat) => (
<TableRow key={stat.date || 'unknown'}>
<TableCell>{stat.date ? format(new Date(stat.date), 'MMM d, yyyy') : 'N/A'}</TableCell>
<TableCell className="text-right">{stat.total_attempts ?? 0}</TableCell>
<TableCell className="text-right">{stat.duplicates_prevented ?? 0}</TableCell>
<TableCell className="text-right">{stat.prevention_rate !== null ? stat.prevention_rate.toFixed(1) : 'N/A'}%</TableCell>
<TableCell>{getHealthBadge(stat.health_status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Prevented Duplicates</CardTitle>
<CardDescription>Notifications that were blocked due to duplication</CardDescription>
</CardHeader>
<CardContent>
{recentDuplicates.length === 0 ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>No recent duplicates detected</AlertDescription>
</Alert>
) : (
<div className="space-y-2">
{recentDuplicates.map((dup) => (
<div key={dup.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<div className="font-medium">
{dup.profiles?.display_name || dup.profiles?.username || 'Unknown User'}
</div>
<div className="text-xs text-muted-foreground">
Channel: {dup.channel} Key: {dup.idempotency_key?.substring(0, 12)}...
</div>
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(dup.created_at), 'PPp')}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
interface MigrationResult {
userId: string;
email: string;
success: boolean;
error?: string;
}
export function NovuMigrationUtility(): React.JSX.Element {
const { toast } = useToast();
const [isRunning, setIsRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<MigrationResult[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const runMigration = async (): Promise<void> => {
setIsRunning(true);
setResults([]);
setProgress(0);
try {
// Call the server-side migration function
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('You must be logged in to run the migration');
}
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string || 'https://api.thrillwiki.com';
const response = await fetch(
`${supabaseUrl}/functions/v1/migrate-novu-users`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
}
);
const data = await response.json() as { success: boolean; error?: string; results?: MigrationResult[]; total?: number };
if (!response.ok || !data.success) {
throw new Error(data.error || 'Migration failed');
}
if (!data.results || data.results.length === 0) {
toast({
title: "No users to migrate",
description: "All users are already registered with Novu.",
});
setIsRunning(false);
return;
}
setTotalUsers(data.total ?? 0);
setResults(data.results ?? []);
setProgress(100);
const successCount = (data.results ?? []).filter((r: MigrationResult) => r.success).length;
const failureCount = (data.results ?? []).length - successCount;
toast({
title: "Migration completed",
description: `Successfully migrated ${successCount} users. ${failureCount} failures.`,
});
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
toast({
variant: "destructive",
title: "Migration failed",
description: errorMsg,
});
} finally {
setIsRunning(false);
}
};
const successCount = results.filter(r => r.success).length;
const failureCount = results.filter(r => !r.success).length;
return (
<Card>
<CardHeader>
<CardTitle>Novu User Migration</CardTitle>
<CardDescription>
Register existing users with Novu notification service
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This utility will register all existing users who don't have a Novu subscriber ID.
The process is non-blocking and will continue even if individual registrations fail.
</AlertDescription>
</Alert>
<Button
onClick={() => void runMigration()}
loading={isRunning}
loadingText="Migrating Users..."
className="w-full"
>
Start Migration
</Button>
{isRunning && totalUsers > 0 && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Progress</span>
<span>{Math.round(progress)}%</span>
</div>
<Progress value={progress} />
<p className="text-sm text-muted-foreground">
Processing {results.length} of {totalUsers} users
</p>
</div>
)}
{results.length > 0 && (
<div className="space-y-2">
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-4 w-4" />
<span>{successCount} succeeded</span>
</div>
<div className="flex items-center gap-2 text-red-600">
<XCircle className="h-4 w-4" />
<span>{failureCount} failed</span>
</div>
</div>
<div className="max-h-60 overflow-y-auto border rounded-md p-2 space-y-1">
{results.map((result, idx) => (
<div
key={idx}
className="flex items-center justify-between text-xs p-2 rounded bg-muted/50"
>
<span className="truncate flex-1">{result.email}</span>
{result.success ? (
<CheckCircle2 className="h-3 w-3 text-green-600 ml-2" />
) : (
<div className="flex items-center gap-1 ml-2">
<XCircle className="h-3 w-3 text-red-600" />
<span className="text-red-600">{result.error}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,306 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas } from '@/lib/entityValidationSchemas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { FerrisWheel, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type OperatorFormData = z.infer<typeof entitySchemas.operator>;
interface OperatorFormProps {
onSubmit: (data: OperatorFormData) => void;
onCancel: () => void;
initialData?: Partial<OperatorFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps): React.JSX.Element {
const { isModerator } = useUserRole();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm({
resolver: zodResolver(entitySchemas.operator),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'operator' as const,
description: initialData?.description || '',
person_type: initialData?.person_type || ('company' as const),
website_url: initialData?.website_url || '',
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
headquarters_location: initialData?.headquarters_location || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FerrisWheel className="w-5 h-5" />
{initialData ? 'Edit Operator' : 'Create New Operator'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(async (data) => {
if (!user) {
toast.error('You must be logged in to submit');
return;
}
setIsSubmitting(true);
try {
const formData = {
...data,
company_type: 'operator' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: undefined,
banner_image_id: undefined,
banner_image_url: undefined,
card_image_id: undefined,
card_image_url: undefined,
};
await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue
if (!initialData?.id) {
toast.success('Operator submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Operator' : 'Create Operator',
metadata: { companyName: data.name }
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
})} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter operator name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the operator..."
rows={3}
/>
</div>
{/* Person Type */}
<div className="space-y-2">
<Label>Entity Type *</Label>
<RadioGroup
value={watch('person_type')}
onValueChange={(value) => setValue('person_type', value as 'company' | 'individual' | 'firm' | 'organization')}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="company" id="company" />
<Label htmlFor="company" className="cursor-pointer">Company</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="firm" id="firm" />
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="organization" />
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
</div>
</RadioGroup>
</div>
{/* Additional Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="headquarters_location">Headquarters Location</Label>
<HeadquartersLocationInput
value={watch('headquarters_location') || ''}
onChange={(value) => setValue('headquarters_location', value)}
/>
<p className="text-xs text-muted-foreground">
Search OpenStreetMap for accurate location data, or manually enter location name.
</p>
</div>
</div>
{/* Website */}
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://example.com"
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via company website', 'Founded date approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="operator"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{initialData?.id ? 'Update Operator' : 'Create Operator'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,740 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
import { getErrorMessage } from '@/lib/errorHandler';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler';
import { MapPin, Save, X, Plus, AlertCircle } from 'lucide-react';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import { Badge } from '@/components/ui/badge';
import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import type { TempCompanyData } from '@/types/company';
import { LocationSearch } from './LocationSearch';
import { OperatorForm } from './OperatorForm';
import { PropertyOwnerForm } from './PropertyOwnerForm';
import { Checkbox } from '@/components/ui/checkbox';
const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'),
slug: z.string().min(1, 'Slug is required'), // Auto-generated, validated on submit
description: z.string().optional(),
park_type: z.string().min(1, 'Park type is required'),
status: z.string().min(1, 'Status is required'),
opening_date: z.string().optional().transform(val => val || undefined),
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
closing_date: z.string().optional().transform(val => val || undefined),
closing_date_precision: z.enum(['day', 'month', 'year']).optional(),
location: z.object({
name: z.string(),
street_address: z.string().optional(),
city: z.string().optional(),
state_province: z.string().optional(),
country: z.string(),
postal_code: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
timezone: z.string().optional(),
display_name: z.string(),
}).optional(),
location_id: z.string().uuid().optional(),
website_url: z.string().url().optional().or(z.literal('')),
phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
operator_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
property_owner_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
source_url: z.string().url().optional().or(z.literal('')),
submission_notes: z.string().max(1000).optional().or(z.literal('')),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string().optional(),
file: z.instanceof(File).optional(),
isLocal: z.boolean().optional(),
caption: z.string().optional(),
})),
banner_assignment: z.number().nullable().optional(),
card_assignment: z.number().nullable().optional(),
}).optional()
});
type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps {
onSubmit: (data: ParkFormData & {
operator_id?: string;
property_owner_id?: string;
_compositeSubmission?: import('@/types/composite-submission').ParkCompositeSubmission;
}) => Promise<void>;
onCancel?: () => void;
initialData?: Partial<ParkFormData & {
id?: string;
operator_id?: string;
property_owner_id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
isEditing?: boolean;
}
const parkTypes = [
{ value: 'theme_park', label: 'Theme Park' },
{ value: 'amusement_park', label: 'Amusement Park' },
{ value: 'water_park', label: 'Water Park' },
{ value: 'family_entertainment', label: 'Family Entertainment Center' },
{ value: 'adventure_park', label: 'Adventure Park' },
{ value: 'safari_park', label: 'Safari Park' },
{ value: 'carnival', label: 'Carnival' },
{ value: 'fair', label: 'Fair' }
];
const statusOptions = [
'Operating',
'Closed Temporarily',
'Closed Permanently',
'Under Construction',
'Planned',
'Abandoned'
];
// Status mappings
const STATUS_DISPLAY_TO_DB: Record<string, string> = {
'Operating': 'operating',
'Closed Temporarily': 'closed_temporarily',
'Closed Permanently': 'closed_permanently',
'Under Construction': 'under_construction',
'Planned': 'planned',
'Abandoned': 'abandoned'
};
const STATUS_DB_TO_DISPLAY: Record<string, string> = {
'operating': 'Operating',
'closed_temporarily': 'Closed Temporarily',
'closed_permanently': 'Closed Permanently',
'under_construction': 'Under Construction',
'planned': 'Planned',
'abandoned': 'Abandoned'
};
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
const { isModerator } = useUserRole();
// Validate that onSubmit uses submission helpers (dev mode only)
useEffect(() => {
validateSubmissionHandler(onSubmit, 'park');
}, [onSubmit]);
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
// Operator state
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
const [tempNewOperator, setTempNewOperator] = useState<TempCompanyData | null>(null);
const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false);
// Property Owner state
const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState<string>(initialData?.property_owner_id || '');
const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<TempCompanyData | null>(null);
const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false);
// Operator is Owner checkbox state
const [operatorIsOwner, setOperatorIsOwner] = useState<boolean>(
!!(initialData?.operator_id && initialData?.property_owner_id &&
initialData?.operator_id === initialData?.property_owner_id)
);
// Fetch data
const { operators, loading: operatorsLoading } = useOperators();
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
const {
register,
handleSubmit,
setValue,
watch,
trigger,
formState: { errors }
} = useForm<ParkFormData>({
resolver: zodResolver(entitySchemas.park),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
description: initialData?.description || '',
park_type: initialData?.park_type || '',
status: initialData?.status || 'operating' as const, // Store DB value
opening_date: initialData?.opening_date || undefined,
closing_date: initialData?.closing_date || undefined,
location_id: initialData?.location_id || undefined,
website_url: initialData?.website_url || '',
phone: initialData?.phone || '',
email: initialData?.email || '',
operator_id: initialData?.operator_id || undefined,
property_owner_id: initialData?.property_owner_id || undefined,
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: { uploaded: [] }
}
});
// Sync property owner with operator when checkbox is enabled
useEffect(() => {
if (operatorIsOwner && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
}
}, [operatorIsOwner, selectedOperatorId, setValue]);
const handleFormSubmit = async (data: ParkFormData) => {
setIsSubmitting(true);
try {
// Pre-submission validation for required fields
const { valid, errors: validationErrors } = validateRequiredFields('park', data);
if (!valid) {
validationErrors.forEach(error => {
toast({
variant: 'destructive',
title: 'Missing Required Fields',
description: error
});
});
setIsSubmitting(false);
return;
}
// CRITICAL: Block new photo uploads on edits
if (isEditing && data.images?.uploaded) {
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
if (hasNewPhotos) {
toast({
variant: 'destructive',
title: 'Validation Error',
description: 'New photos cannot be added during edits. Please remove new photos or use the photo gallery.'
});
return;
}
}
// Build composite submission if new entities were created
const submissionContent: import('@/types/composite-submission').ParkCompositeSubmission = {
park: data,
};
// Add new operator if created
if (tempNewOperator) {
submissionContent.new_operator = tempNewOperator;
submissionContent.park.operator_id = null;
// If operator is also owner, use same entity for both
if (operatorIsOwner) {
submissionContent.new_property_owner = tempNewOperator;
submissionContent.park.property_owner_id = null;
}
}
// Add new property owner if created (and not already set above)
if (tempNewPropertyOwner && !operatorIsOwner) {
submissionContent.new_property_owner = tempNewPropertyOwner;
submissionContent.park.property_owner_id = null;
}
// Determine final IDs to pass
// When creating new entities via composite submission, IDs should be undefined
// When using existing entities, pass their IDs directly
let finalOperatorId: string | undefined;
let finalPropertyOwnerId: string | undefined;
if (tempNewOperator) {
// New operator being created via composite submission
finalOperatorId = undefined;
finalPropertyOwnerId = operatorIsOwner ? undefined :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
} else {
// Using existing operator
finalOperatorId = selectedOperatorId || undefined;
finalPropertyOwnerId = operatorIsOwner ? finalOperatorId :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
}
// Debug: Log what's being submitted
const submissionData = {
...data,
operator_id: finalOperatorId,
property_owner_id: finalPropertyOwnerId,
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
};
console.info('[ParkForm] Submitting park data:', {
hasLocation: !!submissionData.location,
hasLocationId: !!submissionData.location_id,
locationData: submissionData.location,
parkName: submissionData.name,
isEditing
});
await onSubmit(submissionData);
// Parent component handles success feedback
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
handleError(error, {
action: isEditing ? 'Update Park' : 'Create Park',
userId: user?.id,
metadata: {
parkName: data.name,
hasLocation: !!data.location_id,
hasNewOperator: !!tempNewOperator,
hasNewOwner: !!tempNewPropertyOwner
}
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
{isEditing ? 'Edit Park' : 'Create New Park'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Park Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter park name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the park..."
rows={4}
/>
</div>
{/* Park Type and Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Park Type *</Label>
<Select onValueChange={(value) => setValue('park_type', value)} defaultValue={initialData?.park_type}>
<SelectTrigger>
<SelectValue placeholder="Select park type" />
</SelectTrigger>
<SelectContent>
{parkTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.park_type && (
<p className="text-sm text-destructive">{errors.park_type.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Status *</Label>
<Select
onValueChange={(value) => setValue('status', value)}
defaultValue={initialData?.status || 'operating'}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((displayStatus) => {
const dbValue = STATUS_DISPLAY_TO_DB[displayStatus];
return (
<SelectItem key={dbValue} value={dbValue}>
{displayStatus}
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.status && (
<p className="text-sm text-destructive">{errors.status.message}</p>
)}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FlexibleDateInput
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('opening_date_precision', precision);
}}
label="Opening Date"
placeholder="Select opening date"
disableFuture={true}
fromYear={1800}
/>
<FlexibleDateInput
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('closing_date_precision', precision);
}}
label="Closing Date (if applicable)"
placeholder="Select closing date"
disablePast={false}
fromYear={1800}
/>
</div>
{/* Location */}
<div className="space-y-2">
<Label className="flex items-center gap-1">
Location
<span className="text-destructive">*</span>
</Label>
<LocationSearch
onLocationSelect={(location) => {
console.info('[ParkForm] Location selected:', location);
setValue('location', location);
console.info('[ParkForm] Location set in form:', watch('location'));
// Manually trigger validation for the location field
trigger('location');
}}
initialLocationId={watch('location_id')}
/>
{errors.location && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{errors.location.message}
</p>
)}
{!errors.location && (
<p className="text-sm text-muted-foreground">
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
</p>
)}
</div>
{/* Operator & Property Owner Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
<div className="flex items-center space-x-2 mb-4">
<Checkbox
id="operator-is-owner"
checked={operatorIsOwner}
onCheckedChange={(checked) => {
setOperatorIsOwner(checked as boolean);
if (checked && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
setTempNewPropertyOwner(null);
}
}}
/>
<Label htmlFor="operator-is-owner" className="text-sm font-normal cursor-pointer">
Operator is also the property owner
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Operator Column */}
<div className="space-y-2">
<Label>Park Operator</Label>
{tempNewOperator ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-blue-50 dark:bg-blue-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewOperator.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewOperator(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={operators}
value={watch('operator_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('operator_id', cleanValue);
setSelectedOperatorId(cleanValue || '');
}}
placeholder="Select operator"
searchPlaceholder="Search operators..."
emptyText="No operators found"
loading={operatorsLoading}
/>
)}
{!tempNewOperator && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsOperatorModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Operator
</Button>
)}
</div>
{/* Property Owner Column */}
{!operatorIsOwner && (
<div className="space-y-2">
<Label>Property Owner</Label>
{tempNewPropertyOwner ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-green-50 dark:bg-green-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewPropertyOwner.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewPropertyOwner(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={propertyOwners}
value={watch('property_owner_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('property_owner_id', cleanValue);
setSelectedPropertyOwnerId(cleanValue || '');
}}
placeholder="Select property owner"
searchPlaceholder="Search property owners..."
emptyText="No property owners found"
loading={ownersLoading}
/>
)}
{!tempNewPropertyOwner && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsPropertyOwnerModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Property Owner
</Button>
)}
</div>
)}
</div>
</div>
{/* Contact Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://..."
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
{...register('phone')}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="contact@park.com"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via phone call with park', 'Soft opening date not yet announced')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={isEditing ? 'edit' : 'create'}
value={watch('images') as ImageAssignments}
onChange={(images: ImageAssignments) => setValue('images', images)}
entityType="park"
entityId={isEditing ? initialData?.id : undefined}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Form Actions */}
<div className="flex gap-4 pt-6">
<Button
type="submit"
className="flex-1"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{isEditing ? 'Update Park' : 'Create Park'}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
)}
</div>
</form>
{/* Operator Modal */}
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<OperatorForm
initialData={tempNewOperator || undefined}
onSubmit={(data) => {
setTempNewOperator(data);
setIsOperatorModalOpen(false);
setValue('operator_id', 'temp-operator');
}}
onCancel={() => setIsOperatorModalOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Property Owner Modal */}
<Dialog open={isPropertyOwnerModalOpen} onOpenChange={setIsPropertyOwnerModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<PropertyOwnerForm
initialData={tempNewPropertyOwner || undefined}
onSubmit={(data) => {
setTempNewPropertyOwner(data);
setIsPropertyOwnerModalOpen(false);
setValue('property_owner_id', 'temp-property-owner');
}}
onCancel={() => setIsPropertyOwnerModalOpen(false)}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,125 @@
/**
* Pipeline Health Alerts Component
*
* Displays critical pipeline alerts on the admin error monitoring dashboard.
* Shows top 10 active alerts with severity-based styling and resolution actions.
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useSystemAlerts } from '@/hooks/useSystemHealth';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AlertTriangle, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import { supabase } from '@/lib/supabaseClient';
import { toast } from 'sonner';
const SEVERITY_CONFIG = {
critical: { color: 'destructive', icon: XCircle },
high: { color: 'destructive', icon: AlertCircle },
medium: { color: 'default', icon: AlertTriangle },
low: { color: 'secondary', icon: CheckCircle },
} as const;
const ALERT_TYPE_LABELS: Record<string, string> = {
failed_submissions: 'Failed Submissions',
high_ban_rate: 'High Ban Attempt Rate',
temp_ref_error: 'Temp Reference Error',
orphaned_images: 'Orphaned Images',
slow_approval: 'Slow Approvals',
submission_queue_backlog: 'Queue Backlog',
ban_attempt: 'Ban Attempt',
upload_timeout: 'Upload Timeout',
high_error_rate: 'High Error Rate',
validation_error: 'Validation Error',
stale_submissions: 'Stale Submissions',
circular_dependency: 'Circular Dependency',
rate_limit_violation: 'Rate Limit Violation',
};
export function PipelineHealthAlerts() {
const { data: criticalAlerts } = useSystemAlerts('critical');
const { data: highAlerts } = useSystemAlerts('high');
const { data: mediumAlerts } = useSystemAlerts('medium');
const allAlerts = [
...(criticalAlerts || []),
...(highAlerts || []),
...(mediumAlerts || [])
].slice(0, 10);
const resolveAlert = async (alertId: string) => {
const { error } = await supabase
.from('system_alerts')
.update({ resolved_at: new Date().toISOString() })
.eq('id', alertId);
if (error) {
toast.error('Failed to resolve alert');
} else {
toast.success('Alert resolved');
}
};
if (!allAlerts.length) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" />
Pipeline Health: All Systems Operational
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No active alerts. The sacred pipeline is flowing smoothly.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>🚨 Active Pipeline Alerts</CardTitle>
<CardDescription>
Critical issues requiring attention ({allAlerts.length} active)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{allAlerts.map((alert) => {
const config = SEVERITY_CONFIG[alert.severity];
const Icon = config.icon;
const label = ALERT_TYPE_LABELS[alert.alert_type] || alert.alert_type;
return (
<div
key={alert.id}
className="flex items-start justify-between p-3 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-start gap-3 flex-1">
<Icon className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={config.color as any}>{alert.severity.toUpperCase()}</Badge>
<span className="text-sm font-medium">{label}</span>
</div>
<p className="text-sm text-muted-foreground">{alert.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(alert.created_at), 'PPp')}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => resolveAlert(alert.id)}
>
Resolve
</Button>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import { handleError } from '@/lib/errorHandler';
import { AuditLogEntry } from '@/types/database';
interface ProfileChangeField {
field_name: string;
old_value: string | null;
new_value: string | null;
}
interface ProfileAuditLogWithChanges extends Omit<AuditLogEntry, 'changes'> {
profile_change_fields?: ProfileChangeField[];
}
export function ProfileAuditLog(): React.JSX.Element {
const [logs, setLogs] = useState<ProfileAuditLogWithChanges[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
void fetchAuditLogs();
}, []);
const fetchAuditLogs = async (): Promise<void> => {
try {
const { data, error } = await supabase
.from('profile_audit_log')
.select(`
*,
profiles!user_id(username, display_name),
profile_change_fields(
field_name,
old_value,
new_value
)
`)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
setLogs((data || []) as ProfileAuditLogWithChanges[]);
} catch (error: unknown) {
handleError(error, { action: 'Load audit logs' });
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Profile Audit Log</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Changes</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
{(log as { profiles?: { display_name?: string; username?: string } }).profiles?.display_name || (log as { profiles?: { username?: string } }).profiles?.username || 'Unknown'}
</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
{log.profile_change_fields && log.profile_change_fields.length > 0 ? (
<div className="space-y-1">
{log.profile_change_fields.map((change, idx) => (
<div key={idx} className="text-xs">
<span className="font-medium">{change.field_name}:</span>{' '}
<span className="text-muted-foreground">{change.old_value || 'null'}</span>
{' → '}
<span className="text-foreground">{change.new_value || 'null'}</span>
</div>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">No changes</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'PPpp')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,306 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas } from '@/lib/entityValidationSchemas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Building2, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type PropertyOwnerFormData = z.infer<typeof entitySchemas.property_owner>;
interface PropertyOwnerFormProps {
onSubmit: (data: PropertyOwnerFormData) => void;
onCancel: () => void;
initialData?: Partial<PropertyOwnerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps): React.JSX.Element {
const { isModerator } = useUserRole();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm({
resolver: zodResolver(entitySchemas.property_owner),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'property_owner' as const,
description: initialData?.description || '',
person_type: initialData?.person_type || ('company' as const),
website_url: initialData?.website_url || '',
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
headquarters_location: initialData?.headquarters_location || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5" />
{initialData ? 'Edit Property Owner' : 'Create New Property Owner'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(async (data) => {
if (!user) {
toast.error('You must be logged in to submit');
return;
}
setIsSubmitting(true);
try {
const formData = {
...data,
company_type: 'property_owner' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: undefined,
banner_image_id: undefined,
banner_image_url: undefined,
card_image_id: undefined,
card_image_url: undefined,
};
await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue
if (!initialData?.id) {
toast.success('Property owner submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Property Owner' : 'Create Property Owner',
metadata: { companyName: data.name }
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
})} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter property owner name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the property owner..."
rows={3}
/>
</div>
{/* Person Type */}
<div className="space-y-2">
<Label>Entity Type *</Label>
<RadioGroup
value={watch('person_type')}
onValueChange={(value) => setValue('person_type', value as 'company' | 'individual' | 'firm' | 'organization')}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="company" id="company" />
<Label htmlFor="company" className="cursor-pointer">Company</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="firm" id="firm" />
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="organization" />
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
</div>
</RadioGroup>
</div>
{/* Additional Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="headquarters_location">Headquarters Location</Label>
<HeadquartersLocationInput
value={watch('headquarters_location') || ''}
onChange={(value) => setValue('headquarters_location', value)}
/>
<p className="text-xs text-muted-foreground">
Search OpenStreetMap for accurate location data, or manually enter location name.
</p>
</div>
</div>
{/* Website */}
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://example.com"
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via company website', 'Founded date approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="property_owner"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{initialData?.id ? 'Update Property Owner' : 'Create Property Owner'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import type { RideModelTechnicalSpec } from '@/types/database';
import { getErrorMessage } from '@/lib/errorHandler';
import { handleError } from '@/lib/errorHandler';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Layers, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor';
import { TechnicalSpecification } from '@/types/company';
const rideModelSchema = z.object({
name: z.string().min(1, 'Name is required'),
slug: z.string().min(1, 'Slug is required'),
category: z.string().min(1, 'Category is required'),
ride_type: z.string().min(1, 'Ride type is required'),
description: z.string().optional(),
source_url: z.string().url().optional().or(z.literal('')),
submission_notes: z.string().max(1000).optional().or(z.literal('')),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string().optional(),
file: z.instanceof(File).optional(),
isLocal: z.boolean().optional(),
caption: z.string().optional()
})),
banner_assignment: z.number().nullable().optional(),
card_assignment: z.number().nullable().optional()
}).optional()
});
type RideModelFormData = z.infer<typeof rideModelSchema>;
interface RideModelFormProps {
manufacturerName: string;
manufacturerId?: string;
onSubmit: (data: RideModelFormData & { manufacturer_id?: string; _technical_specifications?: TechnicalSpecification[] }) => void;
onCancel: () => void;
initialData?: Partial<RideModelFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
const categories = [
'roller_coaster',
'flat_ride',
'water_ride',
'dark_ride',
'kiddie_ride',
'transportation'
];
export function RideModelForm({
manufacturerName,
manufacturerId,
onSubmit,
onCancel,
initialData
}: RideModelFormProps) {
const { isModerator } = useUserRole();
const [isSubmitting, setIsSubmitting] = useState(false);
const [technicalSpecs, setTechnicalSpecs] = useState<{
spec_name: string;
spec_value: string;
spec_type: 'string' | 'number' | 'boolean' | 'date';
category?: string;
unit?: string;
display_order: number;
}[]>([]);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm<RideModelFormData>({
resolver: zodResolver(rideModelSchema),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
category: initialData?.category || '',
ride_type: initialData?.ride_type || '',
description: initialData?.description || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
const handleFormSubmit = async (data: RideModelFormData) => {
setIsSubmitting(true);
try {
// Include relational technical specs with extended type
await onSubmit({
...data,
manufacturer_id: manufacturerId,
_technical_specifications: technicalSpecs
});
toast.success('Ride model submitted for review');
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-5 h-5" />
{initialData ? 'Edit Ride Model' : 'Create New Ride Model'}
</CardTitle>
<div className="flex items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">For manufacturer:</span>
<Badge variant="secondary">{manufacturerName}</Badge>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Model Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="e.g. Mega Coaster, Sky Screamer"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Category and Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Category *</Label>
<Select
onValueChange={(value) => setValue('category', value)}
defaultValue={initialData?.category}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category && (
<p className="text-sm text-destructive">{errors.category.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ride_type">Ride Type *</Label>
<Input
id="ride_type"
{...register('ride_type')}
placeholder="e.g. Inverted, Wing Coaster, Pendulum"
/>
{errors.ride_type && (
<p className="text-sm text-destructive">{errors.ride_type.message}</p>
)}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the ride model features and characteristics..."
rows={3}
/>
</div>
{/* Technical Specs */}
<div className="border-t pt-6">
<TechnicalSpecsEditor
specs={technicalSpecs}
onChange={setTechnicalSpecs}
commonSpecs={[
'Typical Track Length',
'Typical Height',
'Typical Speed',
'Standard Train Configuration',
'Typical Capacity',
'Typical Duration'
]}
/>
<p className="text-xs text-muted-foreground mt-2">
General specifications for this model that apply to all installations
</p>
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via manufacturer catalog', 'Model specifications approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="ride_model"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
Save Model
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Progress } from '@/components/ui/progress';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { Beaker, CheckCircle, ChevronDown, Trash2, AlertTriangle } from 'lucide-react';
import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator';
import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { useMFAStepUp } from '@/contexts/MFAStepUpContext';
import { isMFACancelledError } from '@/lib/aalErrorDetection';
const PRESETS = {
small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' },
medium: { label: 'Medium', description: '~125 submissions - Standard testing', counts: '20 parks, 50 rides, 20 companies, 10 models, 25 photo sets' },
large: { label: 'Large', description: '~600 submissions - Performance testing', counts: '100 parks, 250 rides, 100 companies, 50 models, 100 photo sets' },
stress: { label: 'Stress', description: '~2600 submissions - Load testing', counts: '400 parks, 1000 rides, 400 companies, 200 models, 500 photo sets' }
};
interface TestDataResults {
summary: {
operators: number;
property_owners: number;
manufacturers: number;
designers: number;
parks: number;
rides: number;
rideModels: number;
photos: number;
totalPhotoItems: number;
conflicts: number;
versionChains: number;
companies?: number;
};
time?: string;
}
export function TestDataGenerator(): React.JSX.Element {
const { toast } = useToast();
const { requireAAL2 } = useMFAStepUp();
const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small');
const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed');
const [entityTypes, setEntityTypes] = useState({
parks: true,
rides: true,
manufacturers: true,
operators: true,
property_owners: true,
designers: true,
ride_models: true,
photos: true
});
const [options, setOptions] = useState({
includeDependencies: true,
includeConflicts: false,
includeVersionChains: false,
includeEscalated: false,
includeExpiredLocks: false
});
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<TestDataResults | null>(null);
const [stats, setStats] = useState<{
total: number;
pending: number;
approved: number;
operators: number;
property_owners: number;
manufacturers: number;
designers: number;
parks: number;
rides: number;
ride_models: number;
} | null>(null);
const selectedEntityTypes = Object.entries(entityTypes)
.filter(([, enabled]) => enabled)
.map(([type]) => type);
useEffect(() => {
void loadStats();
}, []);
const loadStats = async (): Promise<void> => {
try {
const data = await getTestDataStats();
setStats(data);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load test data stats'
});
}
};
const handleGenerate = async (): Promise<void> => {
setLoading(true);
setResults(null);
try {
const stages = ['companies', 'parks', 'rides', 'photos'] as const;
const allResults = {
operators: 0,
property_owners: 0,
manufacturers: 0,
designers: 0,
parks: 0,
rides: 0,
rideModels: 0,
photos: 0,
totalPhotoItems: 0,
conflicts: 0,
versionChains: 0
};
for (let i = 0; i < stages.length; i++) {
const stage = stages[i];
toast({
title: `Stage ${i + 1}/${stages.length}`,
description: `Generating ${stage}...`,
});
const { data, error } = await invokeWithTracking(
'seed-test-data',
{
preset,
fieldDensity,
entityTypes: selectedEntityTypes,
stage,
...options
},
(await supabase.auth.getUser()).data.user?.id
);
if (error) throw error;
// Merge results
const summary = data.summary as Record<string, number>;
Object.keys(summary).forEach(key => {
if (allResults[key as keyof typeof allResults] !== undefined) {
allResults[key as keyof typeof allResults] += summary[key];
}
});
}
setResults({ summary: allResults });
toast({
title: "Test data generated",
description: `Successfully completed all stages`,
});
await loadStats();
} catch (error: unknown) {
toast({
title: "Generation failed",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleClear = async (): Promise<void> => {
setLoading(true);
try {
// Wrap operation with AAL2 requirement
const { deleted } = await requireAAL2(
() => clearTestData(),
'Clearing test data requires additional verification'
);
await loadStats();
toast({
title: 'Test Data Cleared',
description: `Removed ${deleted} test submissions`
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Clear Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
};
const handleEmergencyCleanup = async (): Promise<void> => {
setLoading(true);
try {
// Wrap operation with AAL2 requirement
const { deleted, errors } = await requireAAL2(
() => TestDataTracker.bulkCleanupAllTestData(),
'Emergency cleanup requires additional verification'
);
await loadStats();
toast({
title: 'Emergency Cleanup Complete',
description: `Deleted ${deleted} test records across all tables${errors > 0 ? `, ${errors} errors` : ''}`
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Emergency Cleanup Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Beaker className="w-5 h-5" />
<CardTitle>Test Data Generator</CardTitle>
</div>
<CardDescription>
Generate comprehensive test submissions with varying field density and photo support
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This will create test data in your database. All test submissions are marked and can be cleared later.
</AlertDescription>
</Alert>
{stats && (
<div className="space-y-2">
<div className="flex gap-4 text-sm text-muted-foreground">
<span>Total Test Data: {stats.total}</span>
<span>Pending: {stats.pending}</span>
<span>Approved: {stats.approved}</span>
</div>
{(stats.operators > 0 || stats.property_owners > 0 || stats.manufacturers > 0 ||
stats.designers > 0 || stats.parks > 0 || stats.rides > 0 || stats.ride_models > 0) && (
<Alert>
<AlertDescription>
<div className="text-sm">
<p className="font-medium mb-2">Available Test Dependencies:</p>
<ul className="space-y-1">
{stats.operators > 0 && <li> {stats.operators} test operator{stats.operators > 1 ? 's' : ''}</li>}
{stats.property_owners > 0 && <li> {stats.property_owners} test property owner{stats.property_owners > 1 ? 's' : ''}</li>}
{stats.manufacturers > 0 && <li> {stats.manufacturers} test manufacturer{stats.manufacturers > 1 ? 's' : ''}</li>}
{stats.designers > 0 && <li> {stats.designers} test designer{stats.designers > 1 ? 's' : ''}</li>}
{stats.parks > 0 && <li> {stats.parks} test park{stats.parks > 1 ? 's' : ''}</li>}
{stats.rides > 0 && <li> {stats.rides} test ride{stats.rides > 1 ? 's' : ''}</li>}
{stats.ride_models > 0 && <li> {stats.ride_models} test ride model{stats.ride_models > 1 ? 's' : ''}</li>}
</ul>
<p className="text-xs text-muted-foreground mt-2">
Enable "Include Dependencies" to link new entities to these existing test entities.
</p>
</div>
</AlertDescription>
</Alert>
)}
{/* Dependency warnings */}
{entityTypes.rides && stats.parks === 0 && !entityTypes.parks && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Missing Dependency:</strong> You've selected "rides" but no parks exist.
Rides require parks to be created first. Either enable "parks" or generate parks separately first.
</AlertDescription>
</Alert>
)}
{entityTypes.ride_models && stats.manufacturers === 0 && !entityTypes.manufacturers && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Missing Dependency:</strong> You've selected "ride models" but no manufacturers exist.
Ride models require manufacturers to be created first. Either enable "manufacturers" or generate manufacturers separately first.
</AlertDescription>
</Alert>
)}
{(entityTypes.parks || entityTypes.rides) &&
stats.operators === 0 && stats.property_owners === 0 &&
!entityTypes.operators && !entityTypes.property_owners && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Optional Dependencies:</strong> Parks and rides can optionally link to operators and property owners.
Consider enabling "operators" and "property_owners" for more realistic test data with proper dependency chains.
</AlertDescription>
</Alert>
)}
</div>
)}
<div className="space-y-4">
<div>
<Label className="text-base font-semibold">Preset</Label>
<RadioGroup value={preset} onValueChange={(v: string) => setPreset(v as 'small' | 'medium' | 'large' | 'stress')} className="mt-2 space-y-3">
{Object.entries(PRESETS).map(([key, { label, description, counts }]) => (
<div key={key} className="flex items-start space-x-2">
<RadioGroupItem value={key} id={key} className="mt-1" />
<div className="flex-1">
<Label htmlFor={key} className="font-medium cursor-pointer">{label}</Label>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">{counts}</p>
</div>
</div>
))}
</RadioGroup>
</div>
<div>
<Label className="text-base font-semibold">Field Population Density</Label>
<p className="text-sm text-muted-foreground mt-1 mb-3">
Controls how many optional fields are populated in generated entities
</p>
<RadioGroup value={fieldDensity} onValueChange={(v: string) => setFieldDensity(v as 'mixed' | 'minimal' | 'standard' | 'maximum')} className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="mixed" id="mixed" />
<Label htmlFor="mixed" className="cursor-pointer">
<span className="font-medium">Mixed</span> - Random levels (most realistic)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="minimal" id="minimal" />
<Label htmlFor="minimal" className="cursor-pointer">
<span className="font-medium">Minimal</span> - Required fields only
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="standard" id="standard" />
<Label htmlFor="standard" className="cursor-pointer">
<span className="font-medium">Standard</span> - 50% optional fields
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="maximum" id="maximum" />
<Label htmlFor="maximum" className="cursor-pointer">
<span className="font-medium">Maximum</span> - All fields + technical data
</Label>
</div>
</RadioGroup>
</div>
<div>
<Label className="text-base font-semibold">Entity Types</Label>
<div className="mt-2 grid grid-cols-2 gap-3">
{Object.entries(entityTypes).map(([key, enabled]) => (
<div key={key} className="flex items-center space-x-2">
<Checkbox
id={key}
checked={enabled}
onCheckedChange={(checked) =>
setEntityTypes({ ...entityTypes, [key]: !!checked })
}
/>
<Label htmlFor={key} className="cursor-pointer capitalize">
{key.replace('_', ' ')}
</Label>
</div>
))}
</div>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium">
<ChevronDown className="w-4 h-4" />
Advanced Options
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{Object.entries(options).map(([key, enabled]) => (
<div key={key} className="flex items-center space-x-2">
<Checkbox
id={key}
checked={enabled}
onCheckedChange={(checked) =>
setOptions({ ...options, [key]: !!checked })
}
/>
<Label htmlFor={key} className="cursor-pointer capitalize">
{key.replace(/([A-Z])/g, ' $1').toLowerCase()}
</Label>
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
{loading && (
<div className="space-y-2">
<Progress value={undefined} />
<p className="text-sm text-center text-muted-foreground">Generating test data...</p>
</div>
)}
{results && (
<Alert>
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
<div className="font-medium mb-2">Test Data Generated Successfully</div>
<ul className="text-sm space-y-1">
<li> Created {results.summary.parks} park submissions</li>
<li> Created {results.summary.rides} ride submissions</li>
<li> Created {results.summary.companies} company submissions</li>
<li> Created {results.summary.rideModels} ride model submissions</li>
{results.summary.photos > 0 && (
<li> Created {results.summary.photos} photo submissions ({results.summary.totalPhotoItems || 0} photos)</li>
)}
<li className="font-medium mt-2">Time taken: {results.time}s</li>
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button
onClick={handleGenerate}
loading={loading}
loadingText="Generating..."
disabled={selectedEntityTypes.length === 0}
>
<Beaker className="w-4 h-4 mr-2" />
Generate Test Data
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={loading}>
<Trash2 className="w-4 h-4 mr-2" />
Clear All Test Data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear All Test Data?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all test submissions marked with is_test_data: true. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClear}>Clear Test Data</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={loading} className="border-destructive text-destructive hover:bg-destructive/10">
<AlertTriangle className="w-4 h-4 mr-2" />
Emergency Cleanup (All Tables)
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emergency Cleanup - Delete ALL Test Data?</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p className="font-medium text-destructive"> This is a nuclear option!</p>
<p>This will delete ALL records marked with is_test_data: true from ALL entity tables, including:</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>Parks, Rides, Companies (operators, manufacturers, etc.)</li>
<li>Ride Models, Photos, Reviews</li>
<li>Entity Versions, Edit History</li>
<li>Moderation Queue submissions</li>
</ul>
<p className="font-medium">This goes far beyond the moderation queue and cannot be undone.</p>
<p className="text-sm">Only use this if normal cleanup fails or you need to completely reset test data.</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleEmergencyCleanup} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete All Test Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ProfileManager } from '@/components/moderation/ProfileManager';
import { UserRoleManager } from '@/components/moderation/UserRoleManager';
import { Shield, UserCheck } from 'lucide-react';
export function UserManagement() {
return (
<div className="space-y-6">
<Tabs defaultValue="profiles" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="profiles" className="flex items-center gap-2">
<UserCheck className="w-4 h-4" />
Users
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Roles
</TabsTrigger>
</TabsList>
<TabsContent value="profiles">
<ProfileManager />
</TabsContent>
<TabsContent value="roles">
<UserRoleManager />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } 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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/lib/supabaseClient';
import { format } from 'date-fns';
import { handleNonCriticalError } from '@/lib/errorHandler';
export function VersionCleanupSettings() {
const [retentionDays, setRetentionDays] = useState(90);
const [lastCleanup, setLastCleanup] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const { toast } = useToast();
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const { data: retention, error: retentionError } = await supabase
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'version_retention_days')
.single();
if (retentionError) throw retentionError;
const { data: cleanup, error: cleanupError } = await supabase
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'last_version_cleanup')
.single();
if (cleanupError) throw cleanupError;
if (retention?.setting_value) {
const retentionValue = typeof retention.setting_value === 'string'
? retention.setting_value
: String(retention.setting_value);
setRetentionDays(Number(retentionValue));
}
if (cleanup?.setting_value && cleanup.setting_value !== 'null') {
const cleanupValue = typeof cleanup.setting_value === 'string'
? cleanup.setting_value.replace(/"/g, '')
: String(cleanup.setting_value);
setLastCleanup(cleanupValue);
}
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load version cleanup settings'
});
toast({
title: 'Error',
description: 'Failed to load cleanup settings',
variant: 'destructive',
});
} finally {
setIsInitialLoad(false);
}
};
const handleSaveRetention = async () => {
setIsSaving(true);
try {
const { error } = await supabase
.from('admin_settings')
.update({ setting_value: retentionDays.toString() })
.eq('setting_key', 'version_retention_days');
if (error) throw error;
toast({
title: 'Settings Saved',
description: 'Retention period updated successfully'
});
} catch (error) {
toast({
title: 'Save Failed',
description: error instanceof Error ? error.message : 'Failed to save settings',
variant: 'destructive'
});
} finally {
setIsSaving(false);
}
};
const handleManualCleanup = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-old-versions', {
body: { manual: true }
});
if (error) throw error;
toast({
title: 'Cleanup Complete',
description: data.message || `Deleted ${data.stats?.item_edit_history_deleted || 0} old versions`,
});
await loadSettings();
} catch (error) {
toast({
title: 'Cleanup Failed',
description: error instanceof Error ? error.message : 'Failed to run cleanup',
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
if (isInitialLoad) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Version History Cleanup</CardTitle>
<CardDescription>
Manage automatic cleanup of old version history records
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="retention">Retention Period (days)</Label>
<div className="flex gap-2">
<Input
id="retention"
type="number"
min={30}
max={365}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value))}
className="w-32"
/>
<Button onClick={handleSaveRetention} loading={isSaving} loadingText="Saving...">
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Keep most recent 10 versions per item, delete older ones beyond this period
</p>
</div>
{lastCleanup ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
Last cleanup: {format(new Date(lastCleanup), 'PPpp')}
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No cleanup has been performed yet
</AlertDescription>
</Alert>
)}
<div className="pt-4 border-t">
<Button
onClick={handleManualCleanup}
loading={isLoading}
loadingText="Running Cleanup..."
variant="outline"
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
Run Manual Cleanup Now
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Automatic cleanup runs every Sunday at 2 AM UTC
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,299 @@
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { toast } from "sonner";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
import { validateMetricUnit } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface CoasterStat {
stat_name: string;
stat_value: number;
unit?: string;
category?: string;
description?: string;
display_order: number;
}
interface CoasterStatsEditorProps {
stats: CoasterStat[];
onChange: (stats: CoasterStat[]) => void;
categories?: string[];
}
const DEFAULT_CATEGORIES = ['Speed', 'Height', 'Length', 'Forces', 'Capacity', 'Duration', 'Other'];
const COMMON_STATS = [
{ name: 'Max Speed', unit: 'km/h', category: 'Speed' },
{ name: 'Max Height', unit: 'm', category: 'Height' },
{ name: 'Drop Height', unit: 'm', category: 'Height' },
{ name: 'Track Length', unit: 'm', category: 'Length' },
{ name: 'Max G-Force', unit: 'G', category: 'Forces' },
{ name: 'Max Negative G-Force', unit: 'G', category: 'Forces' },
{ name: 'Ride Duration', unit: 'seconds', category: 'Duration' },
{ name: 'Inversions', unit: 'count', category: 'Other' },
];
export function CoasterStatsEditor({
stats,
onChange,
categories = DEFAULT_CATEGORIES
}: CoasterStatsEditorProps) {
const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addStat = () => {
onChange([
...stats,
{
stat_name: '',
stat_value: 0,
unit: '',
category: categories[0],
description: '',
display_order: stats.length
}
]);
};
const addCommonStat = (commonStat: typeof COMMON_STATS[0]) => {
onChange([
...stats,
{
stat_name: commonStat.name,
stat_value: 0,
unit: commonStat.unit,
category: commonStat.category,
description: '',
display_order: stats.length
}
]);
};
const removeStat = (index: number) => {
const newStats = stats.filter((_, i) => i !== index);
onChange(newStats.map((stat, i) => ({ ...stat, display_order: i })));
};
const updateStat = (index: number, field: keyof CoasterStat, value: string | number | boolean | null | undefined) => {
const newStats = [...stats];
// Ensure unit is metric when updating unit field
if (field === 'unit' && value && typeof value === 'string') {
try {
validateMetricUnit(value, 'Unit');
newStats[index] = { ...newStats[index], unit: value };
// Clear error for this index
setUnitErrors(prev => {
const updated = { ...prev };
delete updated[index];
return updated;
});
} catch (error: unknown) {
const message = getErrorMessage(error);
toast.error(message);
// Store error for visual feedback
setUnitErrors(prev => ({ ...prev, [index]: message }));
return;
}
} else {
newStats[index] = { ...newStats[index], [field]: value };
}
onChange(newStats);
};
// Get display value (convert from metric to user's preferred units)
const getDisplayValue = (stat: CoasterStat): string => {
if (!stat.stat_value || !stat.unit) return String(stat.stat_value || '');
const numValue = Number(stat.stat_value);
if (isNaN(numValue)) return String(stat.stat_value);
const unitType = detectUnitType(stat.unit);
if (unitType === 'unknown') return String(stat.stat_value);
// stat.unit is the metric unit (e.g., "km/h")
// Get the display unit based on user preference (e.g., "mph" for imperial)
const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system);
// Convert from metric to display unit
const displayValue = convertValueFromMetric(numValue, displayUnit, stat.unit);
return String(displayValue);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Coaster Statistics</Label>
<div className="flex gap-2">
<Select onValueChange={(value) => {
const commonStat = COMMON_STATS.find(s => s.name === value);
if (commonStat) addCommonStat(commonStat);
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add common stat..." />
</SelectTrigger>
<SelectContent>
{COMMON_STATS.map(stat => (
<SelectItem key={stat.name} value={stat.name}>
{stat.name} ({stat.unit})
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" onClick={addStat}>
<Plus className="h-4 w-4 mr-2" />
Add Custom
</Button>
</div>
</div>
{stats.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No statistics added yet. Add a common stat or create a custom one.
</Card>
) : (
<div className="space-y-3">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-xs">Statistic Name</Label>
<Input
value={stat.stat_name}
onChange={(e) => updateStat(index, 'stat_name', e.target.value)}
placeholder="e.g., Max Speed"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Value</Label>
<Input
type="number"
step="0.01"
value={getDisplayValue(stat)}
onChange={(e) => {
const inputValue = e.target.value;
const numValue = parseFloat(inputValue);
if (!isNaN(numValue) && stat.unit) {
// Determine what unit the user is entering (based on their preference)
const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system);
// Convert from user's input unit to metric for storage
const metricValue = convertValueToMetric(numValue, displayUnit);
updateStat(index, 'stat_value', metricValue);
} else {
updateStat(index, 'stat_value', numValue || 0);
}
}}
placeholder="0"
/>
{stat.unit && detectUnitType(stat.unit) !== 'unknown' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(stat.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>
<Label className="text-xs">Unit</Label>
<Input
value={stat.unit || ''}
onChange={(e) => updateStat(index, 'unit', e.target.value)}
placeholder="km/h, m, G..."
className={unitErrors[index] ? 'border-destructive' : ''}
/>
{unitErrors[index] && (
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Use metric units only: km/h, m, cm, kg, G, celsius
</p>
</div>
</div>
<div>
<Label className="text-xs">Category</Label>
<Select
value={stat.category || ''}
onValueChange={(value) => updateStat(index, 'category', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeStat(index)}
className="ml-auto"
>
<Trash2 className="h-4 w-4 text-destructive mr-2" />
Remove
</Button>
</div>
<div className="md:col-span-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
value={stat.description || ''}
onChange={(e) => updateStat(index, 'description', e.target.value)}
placeholder="Additional context about this statistic..."
rows={2}
/>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
/**
* Validates coaster stats before submission
*/
export function validateCoasterStats(stats: CoasterStat[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
stats.forEach((stat, index) => {
if (!stat.stat_name?.trim()) {
errors.push(`Stat ${index + 1}: Name is required`);
}
if (stat.stat_value === null || stat.stat_value === undefined) {
errors.push(`Stat ${index + 1} (${stat.stat_name}): Value is required`);
}
if (stat.unit) {
try {
validateMetricUnit(stat.unit, `Stat ${index + 1} (${stat.stat_name})`);
} catch (error: unknown) {
errors.push(getErrorMessage(error));
}
}
});
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,197 @@
import { Plus, Trash2, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { DatePicker } from "@/components/ui/date-picker";
interface FormerName {
former_name: string;
date_changed?: Date | null;
reason?: string;
from_year?: number;
to_year?: number;
order_index: number;
}
interface FormerNamesEditorProps {
names: FormerName[];
onChange: (names: FormerName[]) => void;
currentName: string;
}
export function FormerNamesEditor({ names, onChange, currentName }: FormerNamesEditorProps) {
const addName = () => {
onChange([
...names,
{
former_name: '',
date_changed: null,
reason: '',
order_index: names.length
}
]);
};
const removeName = (index: number) => {
const newNames = names.filter((_, i) => i !== index);
onChange(newNames.map((name, i) => ({ ...name, order_index: i })));
};
const updateName = (index: number, field: keyof FormerName, value: string | number | Date | null | undefined) => {
const newNames = [...names];
newNames[index] = { ...newNames[index], [field]: value };
onChange(newNames);
};
// Sort names by date_changed (most recent first) for display
const sortedNames = [...names].sort((a, b) => {
if (!a.date_changed && !b.date_changed) return 0;
if (!a.date_changed) return 1;
if (!b.date_changed) return -1;
return new Date(b.date_changed).getTime() - new Date(a.date_changed).getTime();
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Former Names</Label>
<p className="text-xs text-muted-foreground mt-1">
Current name: <span className="font-medium">{currentName}</span>
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addName}>
<Plus className="h-4 w-4 mr-2" />
Add Former Name
</Button>
</div>
{names.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No former names recorded. This entity has kept its original name.
</Card>
) : (
<div className="space-y-3">
{sortedNames.map((name, displayIndex) => {
const actualIndex = names.findIndex(n => n === name);
return (
<Card key={actualIndex} className="p-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-1 space-y-4">
<div>
<Label className="text-xs">Former Name</Label>
<Input
value={name.former_name}
onChange={(e) => updateName(actualIndex, 'former_name', e.target.value)}
placeholder="Enter former name..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs">Date Changed</Label>
<DatePicker
date={name.date_changed ? new Date(name.date_changed) : undefined}
onSelect={(date) => updateName(actualIndex, 'date_changed', date || undefined)}
placeholder="When was the name changed?"
fromYear={1800}
toYear={new Date().getFullYear()}
/>
</div>
<div>
<Label className="text-xs">From Year (optional)</Label>
<Input
type="number"
value={name.from_year || ''}
onChange={(e) => updateName(actualIndex, 'from_year', e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="Year"
min="1800"
max={new Date().getFullYear()}
/>
</div>
<div>
<Label className="text-xs">To Year (optional)</Label>
<Input
type="number"
value={name.to_year || ''}
onChange={(e) => updateName(actualIndex, 'to_year', e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="Year"
min="1800"
max={new Date().getFullYear()}
/>
</div>
</div>
<div>
<Label className="text-xs">Reason for Change (optional)</Label>
<Textarea
value={name.reason || ''}
onChange={(e) => updateName(actualIndex, 'reason', e.target.value)}
placeholder="Why was the name changed?"
rows={2}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeName(actualIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
{name.date_changed && (
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t pt-2">
<Calendar className="h-3 w-3" />
<span>
Changed on {new Date(name.date_changed).toLocaleDateString()}
{name.from_year && name.to_year && ` (used from ${name.from_year} to ${name.to_year})`}
</span>
</div>
)}
</div>
</Card>
);
})}
</div>
)}
{names.length > 0 && (
<Card className="p-4 bg-muted/50">
<div className="text-sm">
<strong>Name Timeline:</strong>
<div className="mt-2 space-y-1">
{sortedNames
.filter(n => n.former_name)
.map((name, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="font-medium">{name.former_name}</span>
{name.from_year && name.to_year && (
<span className="text-muted-foreground">
({name.from_year} - {name.to_year})
</span>
)}
</div>
))}
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="font-medium">{currentName}</span>
<span className="text-muted-foreground">(Current)</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,287 @@
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { toast } from "sonner";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
import { validateMetricUnit, METRIC_UNITS } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface TechnicalSpec {
spec_name: string;
spec_value: string;
spec_type: 'string' | 'number' | 'boolean' | 'date';
category?: string;
unit?: string;
display_order: number;
}
interface TechnicalSpecsEditorProps {
specs: TechnicalSpec[];
onChange: (specs: TechnicalSpec[]) => void;
categories?: string[];
commonSpecs?: string[];
}
const DEFAULT_CATEGORIES = ['Performance', 'Safety', 'Design', 'Capacity', 'Technical', 'Other'];
export function TechnicalSpecsEditor({
specs,
onChange,
categories = DEFAULT_CATEGORIES,
commonSpecs = []
}: TechnicalSpecsEditorProps) {
const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addSpec = () => {
onChange([
...specs,
{
spec_name: '',
spec_value: '',
spec_type: 'string',
category: categories[0],
unit: '',
display_order: specs.length
}
]);
};
const removeSpec = (index: number) => {
const newSpecs = specs.filter((_, i) => i !== index);
// Reorder display_order
onChange(newSpecs.map((spec, i) => ({ ...spec, display_order: i })));
};
const updateSpec = (index: number, field: keyof TechnicalSpec, value: string | number | boolean | null | undefined) => {
const newSpecs = [...specs];
// Ensure unit is metric when updating unit field
if (field === 'unit' && value && typeof value === 'string') {
try {
validateMetricUnit(value, 'Unit');
newSpecs[index] = { ...newSpecs[index], unit: value };
// Clear error for this index
setUnitErrors(prev => {
const updated = { ...prev };
delete updated[index];
return updated;
});
} catch (error: unknown) {
const message = getErrorMessage(error);
toast.error(message);
// Store error for visual feedback
setUnitErrors(prev => ({ ...prev, [index]: message }));
return;
}
} else {
newSpecs[index] = { ...newSpecs[index], [field]: value };
}
onChange(newSpecs);
};
// Get display value (convert from metric to user's preferred units)
const getDisplayValue = (spec: TechnicalSpec): string => {
if (!spec.spec_value || !spec.unit || spec.spec_type !== 'number') return spec.spec_value;
const numValue = parseFloat(spec.spec_value);
if (isNaN(numValue)) return spec.spec_value;
const unitType = detectUnitType(spec.unit);
if (unitType === 'unknown') return spec.spec_value;
// spec.unit is the metric unit (e.g., "km/h")
// Get the display unit based on user preference (e.g., "mph" for imperial)
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
// Convert from metric to display unit
const displayValue = convertValueFromMetric(numValue, displayUnit, spec.unit);
return String(displayValue);
};
const moveSpec = (index: number, direction: 'up' | 'down') => {
if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) {
return;
}
const newSpecs = [...specs];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
[newSpecs[index], newSpecs[swapIndex]] = [newSpecs[swapIndex], newSpecs[index]];
// Update display_order
newSpecs[index].display_order = index;
newSpecs[swapIndex].display_order = swapIndex;
onChange(newSpecs);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Technical Specifications</Label>
<Button type="button" variant="outline" size="sm" onClick={addSpec}>
<Plus className="h-4 w-4 mr-2" />
Add Specification
</Button>
</div>
{specs.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No specifications added yet. Click "Add Specification" to get started.
</Card>
) : (
<div className="space-y-3">
{specs.map((spec, index) => (
<Card key={index} className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
<div className="lg:col-span-2">
<Label className="text-xs">Specification Name</Label>
<Input
value={spec.spec_name}
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
placeholder="e.g., Track Material"
list={`common-specs-${index}`}
/>
{commonSpecs.length > 0 && (
<datalist id={`common-specs-${index}`}>
{commonSpecs.map(s => <option key={s} value={s} />)}
</datalist>
)}
</div>
<div>
<Label className="text-xs">Value</Label>
<Input
value={getDisplayValue(spec)}
onChange={(e) => {
const inputValue = e.target.value;
const numValue = parseFloat(inputValue);
// If type is number and unit is recognized, convert to metric for storage
if (spec.spec_type === 'number' && spec.unit && !isNaN(numValue)) {
// Determine what unit the user is entering (based on their preference)
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
// Convert from user's input unit to metric for storage
const metricValue = convertValueToMetric(numValue, displayUnit);
updateSpec(index, 'spec_value', String(metricValue));
} else {
updateSpec(index, 'spec_value', inputValue);
}
}}
placeholder="Value"
type={spec.spec_type === 'number' ? 'number' : 'text'}
/>
{spec.spec_type === 'number' && spec.unit && detectUnitType(spec.unit) !== 'unknown' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(spec.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={spec.spec_type}
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Yes/No</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Category</Label>
<Select
value={spec.category || ''}
onValueChange={(value) => updateSpec(index, 'category', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-xs">Unit</Label>
<Input
value={spec.unit || ''}
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
placeholder="Unit"
list={`units-${index}`}
className={unitErrors[index] ? 'border-destructive' : ''}
/>
<datalist id={`units-${index}`}>
{METRIC_UNITS.map(u => <option key={u} value={u} />)}
</datalist>
{unitErrors[index] && (
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Metric units only
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeSpec(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
/**
* Validates technical specs before submission
*/
export function validateTechnicalSpecs(specs: TechnicalSpec[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
specs.forEach((spec, index) => {
if (!spec.spec_name?.trim()) {
errors.push(`Spec ${index + 1}: Name is required`);
}
if (!spec.spec_value?.trim()) {
errors.push(`Spec ${index + 1} (${spec.spec_name}): Value is required`);
}
if (spec.unit) {
try {
validateMetricUnit(spec.unit, `Spec ${index + 1} (${spec.spec_name})`);
} catch (error: unknown) {
errors.push(getErrorMessage(error));
}
}
});
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,19 @@
// Admin components barrel exports
export { AdminPageLayout } from './AdminPageLayout';
export { ApprovalFailureModal } from './ApprovalFailureModal';
export { BanUserDialog } from './BanUserDialog';
export { DesignerForm } from './DesignerForm';
export { HeadquartersLocationInput } from './HeadquartersLocationInput';
export { LocationSearch } from './LocationSearch';
export { ManufacturerForm } from './ManufacturerForm';
export { MarkdownEditor } from './MarkdownEditor';
export { NovuMigrationUtility } from './NovuMigrationUtility';
export { OperatorForm } from './OperatorForm';
export { ParkForm } from './ParkForm';
export { ProfileAuditLog } from './ProfileAuditLog';
export { PropertyOwnerForm } from './PropertyOwnerForm';
export { RideForm } from './RideForm';
export { RideModelForm } from './RideModelForm';
export { SystemActivityLog } from './SystemActivityLog';
export { TestDataGenerator } from './TestDataGenerator';
export { UserManagement } from './UserManagement';

View File

@@ -0,0 +1,61 @@
/**
* UI components for Park and Designer creation within RideForm
* Extracted for clarity - import these into RideForm.tsx
*/
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Plus, Building2, X } from 'lucide-react';
import type { TempParkData, TempCompanyData } from '@/types/company';
interface ParkSelectorProps {
tempNewPark: TempParkData | null;
onCreateNew: () => void;
onEdit: () => void;
onRemove: () => void;
parkId?: string;
onParkChange: (id: string) => void;
}
interface DesignerSelectorProps {
tempNewDesigner: TempCompanyData | null;
onCreateNew: () => void;
onEdit: () => void;
onRemove: () => void;
designerId?: string;
onDesignerChange: (id: string) => void;
}
export function RideParkSelector({ tempNewPark, onCreateNew, onEdit, onRemove }: ParkSelectorProps) {
return tempNewPark ? (
<div className="space-y-2">
<Badge variant="secondary" className="gap-2">
<Building2 className="h-3 w-3" />
New: {tempNewPark.name}
<button type="button" onClick={onRemove} className="ml-1 hover:text-destructive">×</button>
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onEdit}>Edit New Park</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={onCreateNew}>
<Plus className="h-4 w-4 mr-2" />Create New Park
</Button>
);
}
export function RideDesignerSelector({ tempNewDesigner, onCreateNew, onEdit, onRemove }: DesignerSelectorProps) {
return tempNewDesigner ? (
<div className="space-y-2">
<Badge variant="secondary" className="gap-2">
<Building2 className="h-3 w-3" />
New: {tempNewDesigner.name}
<button type="button" onClick={onRemove} className="ml-1 hover:text-destructive">×</button>
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onEdit}>Edit New Designer</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={onCreateNew}>
<Plus className="h-4 w-4 mr-2" />Create New Designer
</Button>
);
}

View File

@@ -0,0 +1,37 @@
import { Analytics } from "@vercel/analytics/react";
import { Component, ReactNode } from "react";
import { logger } from "@/lib/logger";
class AnalyticsErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
// Silently fail - analytics should never break the app
logger.info('Analytics failed to load, continuing without analytics', { error: error.message });
}
render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
export function AnalyticsWrapper() {
return (
<AnalyticsErrorBoundary>
<Analytics />
</AnalyticsErrorBoundary>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/ui/user-avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { User, Settings, LogOut } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { useToast } from '@/hooks/use-toast';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
export function AuthButtons() {
const { user, loading: authLoading, signOut } = useAuth();
const { data: profile, isLoading: profileLoading } = useProfile(user?.id);
const navigate = useNavigate();
const { toast } = useToast();
const { openAuthModal } = useAuthModal();
const [loggingOut, setLoggingOut] = useState(false);
const handleSignOut = async () => {
setLoggingOut(true);
try {
await signOut();
toast({
title: "Signed out",
description: "You've been signed out successfully."
});
navigate('/');
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
toast({
variant: "destructive",
title: "Error signing out",
description: errorMsg
});
} finally {
setLoggingOut(false);
}
};
// Show loading skeleton only during initial auth check
if (authLoading) {
return (
<div className="flex gap-2 items-center">
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
<div className="h-8 w-8 bg-muted animate-pulse rounded-full" />
</div>
);
}
if (!user) {
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => openAuthModal('signin')}
>
Sign In
</Button>
<Button
size="sm"
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
onClick={() => openAuthModal('signup')}
>
Join ThrillWiki
</Button>
</>
);
}
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<UserAvatar
key={profile?.avatar_url || 'no-avatar'}
avatarUrl={profile?.avatar_url}
fallbackText={profile?.display_name || profile?.username || user.email || 'U'}
size="sm"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{profile?.display_name || profile?.username}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} disabled={loggingOut}>
<LogOut className="mr-2 h-4 w-4" />
<span>{loggingOut ? 'Signing out...' : 'Sign out'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { authStorage } from '@/lib/authStorage';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { logger } from '@/lib/logger';
interface AuthDiagnosticsData {
timestamp: string;
storage: {
type: string;
persistent: boolean;
};
session: {
exists: boolean;
user: string | null;
expiresAt: number | null;
error: string | null;
};
network: {
online: boolean;
};
environment: {
url: string;
isIframe: boolean;
cookiesEnabled: boolean;
};
}
export function AuthDiagnostics() {
const [diagnostics, setDiagnostics] = useState<AuthDiagnosticsData | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const runDiagnostics = async () => {
setIsRefreshing(true);
try {
const storageStatus = authStorage.getStorageStatus();
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
const results = {
timestamp: new Date().toISOString(),
storage: storageStatus,
session: {
exists: !!session,
user: session?.user?.email || null,
expiresAt: session?.expires_at || null,
error: sessionError?.message || null,
},
network: {
online: navigator.onLine,
},
environment: {
url: window.location.href,
isIframe: window.self !== window.top,
cookiesEnabled: navigator.cookieEnabled,
}
};
setDiagnostics(results);
logger.debug('Auth diagnostics', { results });
} finally {
setIsRefreshing(false);
}
};
useEffect(() => {
// Run diagnostics on mount if there's a session issue
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
await runDiagnostics();
setIsOpen(true);
}
};
// Only run if not already authenticated
const timer = setTimeout(checkSession, 3000);
return () => clearTimeout(timer);
}, []);
if (!isOpen || !diagnostics) return null;
return (
<Card className="fixed bottom-4 right-4 w-96 z-50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Authentication Diagnostics</span>
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}></Button>
</CardTitle>
<CardDescription>Debug information for session issues</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<strong>Storage Type:</strong>{' '}
<Badge variant={diagnostics.storage.persistent ? 'default' : 'destructive'}>
{diagnostics.storage.type}
</Badge>
</div>
<div>
<strong>Session Status:</strong>{' '}
<Badge variant={diagnostics.session.exists ? 'default' : 'destructive'}>
{diagnostics.session.exists ? 'Active' : 'None'}
</Badge>
</div>
{diagnostics.session.user && (
<div className="text-xs text-muted-foreground">
User: {diagnostics.session.user}
</div>
)}
{diagnostics.session.error && (
<div className="text-xs text-destructive">
Error: {diagnostics.session.error}
</div>
)}
<div>
<strong>Cookies Enabled:</strong>{' '}
<Badge variant={diagnostics.environment.cookiesEnabled ? 'default' : 'destructive'}>
{diagnostics.environment.cookiesEnabled ? 'Yes' : 'No'}
</Badge>
</div>
{diagnostics.environment.isIframe && (
<div className="text-xs text-yellow-600 dark:text-yellow-400">
Running in iframe - storage may be restricted
</div>
)}
<Button onClick={runDiagnostics} loading={isRefreshing} loadingText="Refreshing..." variant="outline" size="sm" className="w-full mt-2">
Refresh Diagnostics
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,682 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { Zap, Mail, Lock, User, Eye, EyeOff } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { handleError, handleNonCriticalError } from '@/lib/errorHandler';
import { TurnstileCaptcha } from './TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService';
import { useCaptchaBypass } from '@/hooks/useCaptchaBypass';
import { MFAChallenge } from './MFAChallenge';
import { verifyMfaUpgrade } from '@/lib/authService';
import { setAuthMethod } from '@/lib/sessionFlags';
import { validateEmailNotDisposable } from '@/lib/emailValidation';
import type { SignInOptions } from '@/types/supabase-auth';
interface AuthModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTab?: 'signin' | 'signup';
}
export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthModalProps) {
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 [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
username: '',
displayName: ''
});
const { requireCaptcha } = useCaptchaBypass();
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);
if (requireCaptcha && !signInCaptchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
const tokenToUse = signInCaptchaToken;
setSignInCaptchaToken(null);
try {
const signInOptions: SignInOptions = {
email: formData.email,
password: formData.password,
};
if (tokenToUse) {
signInOptions.options = { captchaToken: tokenToUse };
}
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
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 in modal, show MFA challenge
}
}
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
// Wait for auth state to propagate before closing
await new Promise(resolve => setTimeout(resolve, 100));
onOpenChange(false);
} catch (error: unknown) {
setSignInCaptchaKey(prev => prev + 1);
handleError(error, {
action: 'Sign In',
metadata: {
method: 'password',
hasCaptcha: !!tokenToUse
// ⚠️ NEVER log: email, password, tokens
}
});
} 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);
onOpenChange(false);
};
const handleMfaCancel = () => {
setMfaFactorId(null);
setSignInCaptchaKey(prev => prev + 1);
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (formData.password !== formData.confirmPassword) {
toast({
variant: "destructive",
title: "Passwords don't match",
description: "Please make sure your passwords match."
});
setLoading(false);
return;
}
if (formData.password.length < 6) {
toast({
variant: "destructive",
title: "Password too short",
description: "Password must be at least 6 characters long."
});
setLoading(false);
return;
}
if (requireCaptcha && !captchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
const tokenToUse = captchaToken;
setCaptchaToken(null);
try {
// Validate email is not disposable
const emailValidation = await validateEmailNotDisposable(formData.email);
if (!emailValidation.valid) {
toast({
variant: "destructive",
title: "Invalid Email",
description: emailValidation.reason || "Please use a permanent email address"
});
setCaptchaKey(prev => prev + 1);
setLoading(false);
return;
}
interface SignUpOptions {
email: string;
password: string;
options?: {
captchaToken?: string;
data?: {
username: string;
display_name: string;
};
};
}
const signUpOptions: SignUpOptions = {
email: formData.email,
password: formData.password,
options: {
data: {
username: formData.username,
display_name: formData.displayName
}
}
};
if (tokenToUse) {
signUpOptions.options = {
...signUpOptions.options,
captchaToken: tokenToUse
};
}
const { data, error } = await supabase.auth.signUp(signUpOptions);
if (error) throw error;
if (data.user) {
const userId = data.user.id;
notificationService.createSubscriber({
subscriberId: userId,
email: formData.email,
firstName: formData.username,
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."
});
onOpenChange(false);
} catch (error: unknown) {
setCaptchaKey(prev => prev + 1);
handleError(error, {
action: 'Sign Up',
metadata: {
hasCaptcha: !!tokenToUse,
hasUsername: !!formData.username
// ⚠️ NEVER log: email, password, username
}
});
} 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."
});
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Send Magic Link',
metadata: {
method: 'magic_link'
// ⚠️ NEVER log: email, link
}
});
} 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: unknown) {
handleError(error, {
action: 'Social Sign In',
metadata: {
provider,
method: 'oauth'
}
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="text-center text-2xl bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
ThrillWiki
</DialogTitle>
<DialogDescription className="text-center">
Join the ultimate theme park community
</DialogDescription>
</DialogHeader>
<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" className="space-y-4 mt-4">
{mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="modal-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="modal-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="modal-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="modal-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>
{requireCaptcha && (
<div>
<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"
disabled={loading || (requireCaptcha && !signInCaptchaToken)}
>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div>
<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>
<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>
</>
)}
</TabsContent>
<TabsContent value="signup" className="space-y-3 sm:space-y-4 mt-4">
<form onSubmit={handleSignUp} className="space-y-3 sm:space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="modal-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="modal-username"
name="username"
placeholder="username"
value={formData.username}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-displayName">Display Name</Label>
<Input
id="modal-displayName"
name="displayName"
placeholder="Display Name"
value={formData.displayName}
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-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="modal-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="modal-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="modal-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="modal-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="modal-confirmPassword"
name="confirmPassword"
type={showPassword ? "text" : "password"}
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={handleInputChange}
className="pl-10"
autoComplete="new-password"
required
/>
</div>
</div>
{requireCaptcha && (
<div>
<TurnstileCaptcha
key={captchaKey}
onSuccess={setCaptchaToken}
onError={() => setCaptchaToken(null)}
onExpire={() => setCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading || (requireCaptcha && !captchaToken)}
>
{loading ? "Creating account..." : "Create Account"}
</Button>
</form>
<div>
<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" type="button">
<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" type="button">
<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>
<p className="text-xs text-center text-muted-foreground">
By signing up, you agree to our{' '}
<a href="/terms" className="underline hover:text-foreground">Terms</a>
{' '}and{' '}
<a href="/privacy" className="underline hover:text-foreground">Privacy Policy</a>
</p>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { MFAChallenge } from './MFAChallenge';
import { Shield, AlertCircle, Loader2 } from 'lucide-react';
import { getEnrolledFactors } from '@/lib/authService';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
interface AutoMFAVerificationModalProps {
open: boolean;
onSuccess: () => void;
onCancel: () => void;
}
export function AutoMFAVerificationModal({
open,
onSuccess,
onCancel
}: AutoMFAVerificationModalProps) {
const { session } = useAuth();
const [factorId, setFactorId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch enrolled factor automatically when modal opens
useEffect(() => {
if (!open || !session) return;
const fetchFactor = async () => {
setLoading(true);
setError(null);
try {
const factors = await getEnrolledFactors();
if (factors.length === 0) {
setError('No MFA method enrolled. Please set up MFA in settings.');
return;
}
// Use the first verified TOTP factor
const totpFactor = factors.find(f => f.factor_type === 'totp');
if (totpFactor) {
setFactorId(totpFactor.id);
} else {
setError('No valid MFA method found. Please check your security settings.');
}
} catch (err) {
setError('Failed to load MFA settings. Please try again.');
handleError(err, {
action: 'Fetch MFA Factors for Auto-Verification',
metadata: { context: 'AutoMFAVerificationModal' }
});
} finally {
setLoading(false);
}
};
fetchFactor();
}, [open, session]);
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onCancel();
}
}}
>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
Your session requires Multi-Factor Authentication to access this area.
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex flex-col items-center justify-center py-8 space-y-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading verification...</p>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-center text-muted-foreground">{error}</p>
</div>
)}
{!loading && !error && factorId && (
<MFAChallenge
factorId={factorId}
onSuccess={onSuccess}
onCancel={onCancel}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { supabase } from "@/lib/supabaseClient";
import { useToast } from "@/hooks/use-toast";
import { handleError } from "@/lib/errorHandler";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Shield } from "lucide-react";
interface MFAChallengeProps {
factorId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const handleVerify = async () => {
if (code.length !== 6) return;
setLoading(true);
try {
// Create fresh challenge for each verification attempt
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ factorId });
if (challengeError) throw challengeError;
// Immediately verify with fresh challenge
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: code.trim(),
});
if (error) throw error;
if (data) {
toast({
title: "Welcome back!",
description: "Multi-Factor verification successful.",
});
onSuccess();
}
} catch (error: unknown) {
handleError(error, {
action: 'MFA Verification',
metadata: {
factorId,
codeLength: code.length,
context: 'MFAChallenge'
}
});
setCode("");
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-primary">
<Shield className="w-5 h-5" />
<h3 className="font-semibold">Multi-Factor Authentication</h3>
</div>
<p className="text-sm text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
<div className="space-y-2">
<Label htmlFor="mfa-code">Authentication Code</Label>
<div className="flex justify-center">
<InputOTP maxLength={6} value={code} onChange={setCode} onComplete={handleVerify}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1" disabled={loading}>
Cancel
</Button>
<Button onClick={handleVerify} className="flex-1" disabled={code.length !== 6 || loading}>
{loading ? "Verifying..." : "Verify"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export function MFAEnrollmentRequired() {
const navigate = useNavigate();
return (
<Alert variant="destructive" className="my-4">
<Shield className="h-4 w-4" />
<AlertTitle>Multi-Factor Authentication Setup Required</AlertTitle>
<AlertDescription className="mt-2 space-y-3">
<p>
Your role requires Multi-Factor Authentication. Please set up MFA to access this area.
</p>
<Button
onClick={() => navigate('/settings?tab=security')}
size="sm"
>
Set up Multi-Factor Authentication
</Button>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,74 @@
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { AutoMFAVerificationModal } from './AutoMFAVerificationModal';
import { MFAEnrollmentRequired } from './MFAEnrollmentRequired';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler';
interface MFAGuardProps {
children: React.ReactNode;
}
/**
* Smart MFA guard that automatically shows verification modal or enrollment alert
*
* Usage:
* ```tsx
* <MFAGuard>
* <YourProtectedContent />
* </MFAGuard>
* ```
*/
export function MFAGuard({ children }: MFAGuardProps) {
const { needsEnrollment, needsVerification, loading } = useRequireMFA();
const { verifySession } = useAuth();
const { toast } = useToast();
const handleVerificationSuccess = async () => {
try {
// Refresh the session to get updated AAL level
await verifySession();
toast({
title: 'Verification Successful',
description: 'You can now access this area.',
});
} catch (error: unknown) {
handleError(error, {
action: 'MFA Session Verification',
metadata: { context: 'MFAGuard' }
});
// Still attempt to show content - session might be valid despite refresh error
}
};
const handleVerificationCancel = () => {
// Redirect back to main dashboard
window.location.href = '/';
};
// Show verification modal automatically when needed
if (needsVerification) {
return (
<>
<AutoMFAVerificationModal
open={true}
onSuccess={handleVerificationSuccess}
onCancel={handleVerificationCancel}
/>
{/* Show blurred content behind modal */}
<div className="pointer-events-none opacity-50 blur-sm">
{children}
</div>
</>
);
}
// Show enrollment alert when user hasn't set up MFA
if (needsEnrollment) {
return <MFAEnrollmentRequired />;
}
// User has MFA and is verified - show content
return <>{children}</>;
}

View File

@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { getSessionAAL } from '@/types/supabase-session';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Shield, AlertTriangle } from 'lucide-react';
interface MFARemovalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
factorId: string;
onSuccess: () => void;
}
export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) {
const { requiresMFA } = useRequireMFA();
const { toast } = useToast();
const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [loading, setLoading] = useState(false);
// Phase 1: Check AAL2 requirement on dialog open
useEffect(() => {
if (open) {
const checkAalLevel = async (): Promise<void> => {
const { data: { session } } = await supabase.auth.getSession();
const currentAal = getSessionAAL(session);
if (currentAal !== 'aal2') {
toast({
title: 'Multi-Factor Authentication Required',
description: 'Please verify your identity with Multi-Factor Authentication before making security changes',
variant: 'destructive'
});
onOpenChange(false);
}
};
checkAalLevel();
}
}, [open, onOpenChange]);
const handleClose = () => {
setStep('password');
setPassword('');
setTotpCode('');
setLoading(false);
onOpenChange(false);
};
const handlePasswordVerification = async () => {
if (!password.trim()) {
toast({
title: 'Password Required',
description: 'Please enter your password',
variant: 'destructive'
});
return;
}
setLoading(true);
try {
// Get current user email
const { data: { user } } = await supabase.auth.getUser();
if (!user?.email) throw new Error('User email not found');
// Re-authenticate with password
const { error } = await supabase.auth.signInWithPassword({
email: user.email,
password: password
});
if (error) throw error;
toast({
title: 'Password Verified',
description: 'Password verified successfully'
});
setStep('totp');
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleTOTPVerification = async () => {
if (!totpCode.trim() || totpCode.length !== 6) {
toast({
title: 'Invalid Code',
description: 'Please enter a valid 6-digit code',
variant: 'destructive'
});
return;
}
setLoading(true);
try {
// Create challenge
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId
});
if (challengeError) throw challengeError;
// Verify TOTP code
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: totpCode.trim()
});
if (verifyError) throw verifyError;
// Phase 1: Verify session is at AAL2 after TOTP verification
const { data: { session } } = await supabase.auth.getSession();
const currentAal = getSessionAAL(session);
if (currentAal !== 'aal2') {
throw new Error('Session must be at AAL2 to remove MFA');
}
toast({
title: 'Code Verified',
description: 'TOTP code verified successfully'
});
setStep('confirm');
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleMFARemoval = async () => {
// Phase 2: Check if user's role requires MFA
if (requiresMFA) {
toast({
title: 'Multi-Factor Authentication Required',
description: 'Your role requires Multi-Factor Authentication and it cannot be disabled',
variant: 'destructive'
});
handleClose();
return;
}
setLoading(true);
try {
// Phase 3: Call edge function instead of direct unenroll
const { data, error, requestId } = await invokeWithTracking(
'mfa-unenroll',
{ factorId },
(await supabase.auth.getUser()).data.user?.id
);
if (error) throw error;
if (data?.error) throw new Error(data.error);
toast({
title: 'Multi-Factor Authentication Disabled',
description: 'Multi-Factor Authentication has been disabled'
});
handleClose();
onSuccess();
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-destructive" />
Disable Multi-Factor Authentication
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Disabling Multi-Factor Authentication will make your account less secure. You'll need to verify your identity first.
</AlertDescription>
</Alert>
{step === 'password' && (
<div className="space-y-3">
<p className="text-sm">Step 1 of 3: Enter your password to continue</p>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePasswordVerification()}
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
)}
{step === 'totp' && (
<div className="space-y-3">
<p className="text-sm">Step 2 of 3: Enter your current code</p>
<div className="space-y-2">
<Label htmlFor="totp">Code from Authenticator App</Label>
<Input
id="totp"
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => e.key === 'Enter' && handleTOTPVerification()}
placeholder="000000"
maxLength={6}
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-3">
<p className="text-sm font-semibold">Step 3 of 3: Final confirmation</p>
<p className="text-sm text-muted-foreground">
Are you sure you want to disable Multi-Factor Authentication? This will:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Remove Multi-Factor Authentication protection from your account</li>
<li>Send a security notification email</li>
<li>Log this action in your security history</li>
</ul>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose} disabled={loading}>
Cancel
</AlertDialogCancel>
{step === 'password' && (
<Button onClick={handlePasswordVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'totp' && (
<Button onClick={handleTOTPVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'confirm' && (
<AlertDialogAction onClick={handleMFARemoval} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? 'Disabling...' : 'Disable Multi-Factor Authentication'}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,34 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { MFAChallenge } from './MFAChallenge';
import { Shield } from 'lucide-react';
interface MFAStepUpModalProps {
open: boolean;
factorId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Additional Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
Your role requires Multi-Factor Authentication. Please verify your identity to continue.
</DialogDescription>
</DialogHeader>
<MFAChallenge
factorId={factorId}
onSuccess={onSuccess}
onCancel={onCancel}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,26 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { authStorage } from "@/lib/authStorage";
import { useEffect, useState } from "react";
export function StorageWarning() {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
const status = authStorage.getStorageStatus();
setShowWarning(!status.persistent);
}, []);
if (!showWarning) return null;
return (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Storage Restricted</AlertTitle>
<AlertDescription>
Your browser is blocking session storage. You'll need to sign in again if you reload the page.
To fix this, please enable cookies and local storage for this site.
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,331 @@
import { useState, useEffect } from 'react';
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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { handleError, handleSuccess, handleInfo, handleNonCriticalError, AppError, getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { supabase } from '@/lib/supabaseClient';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2, AlertTriangle } from 'lucide-react';
import { MFARemovalDialog } from './MFARemovalDialog';
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
import { useNavigate } from 'react-router-dom';
import type { MFAFactor } from '@/types/auth';
export function TOTPSetup() {
const { user } = useAuth();
const { requiresMFA } = useRequireMFA();
const navigate = useNavigate();
const [factors, setFactors] = useState<MFAFactor[]>([]);
const [loading, setLoading] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const [qrCode, setQrCode] = useState('');
const [secret, setSecret] = useState('');
const [factorId, setFactorId] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [showRemovalDialog, setShowRemovalDialog] = useState(false);
useEffect(() => {
fetchTOTPFactors();
}, [user]);
const fetchTOTPFactors = async () => {
if (!user) return;
try {
const { data, error } = await supabase.auth.mfa.listFactors();
if (error) throw error;
const totpFactors = (data.totp || []).map(factor => ({
id: factor.id,
friendly_name: factor.friendly_name || 'Authenticator App',
factor_type: 'totp' as const,
status: factor.status as 'verified' | 'unverified',
created_at: factor.created_at,
updated_at: factor.updated_at
}));
setFactors(totpFactors);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Fetch TOTP factors',
userId: user?.id,
metadata: { context: 'initial_load' }
});
}
};
const startEnrollment = async () => {
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App'
});
if (error) throw error;
setQrCode(data.totp.qr_code);
setSecret(data.totp.secret);
setFactorId(data.id);
setEnrolling(true);
} catch (error: unknown) {
handleError(
new AppError(
getErrorMessage(error) || 'Failed to start TOTP enrollment',
'TOTP_ENROLL_FAILED'
),
{ action: 'Start TOTP enrollment', userId: user?.id }
);
} finally {
setLoading(false);
}
};
const verifyAndEnable = async () => {
if (!factorId || !verificationCode.trim()) {
handleError(
new AppError('Please enter the verification code', 'INVALID_INPUT'),
{ action: 'Verify TOTP', userId: user?.id, metadata: { step: 'code_entry' } }
);
return;
}
setLoading(true);
try {
// Step 1: Create a challenge first
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId
});
if (challengeError) throw challengeError;
// Step 2: Verify using the challengeId from the challenge response
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: verificationCode.trim()
});
if (verifyError) throw verifyError;
// Check if user signed in via OAuth and trigger step-up flow
const authMethod = getAuthMethod();
const isOAuthUser = authMethod === 'oauth';
if (isOAuthUser) {
setStepUpRequired(true, window.location.pathname);
navigate('/auth/mfa-step-up');
return;
}
handleSuccess(
'Multi-Factor Authentication Enabled',
isOAuthUser
? 'Please verify with your authenticator app to continue.'
: 'Please sign in again to activate Multi-Factor Authentication protection.'
);
if (isOAuthUser) {
// Already handled above with navigate
return;
} else {
// For email/password users, force sign out to require MFA on next login
setTimeout(async () => {
await supabase.auth.signOut();
window.location.href = '/auth';
}, 2000);
}
} catch (error: unknown) {
handleError(
new AppError(
getErrorMessage(error) || 'Invalid verification code. Please try again.',
'TOTP_VERIFY_FAILED'
),
{ action: 'Verify TOTP code', userId: user?.id, metadata: { factorId } }
);
} finally {
setLoading(false);
}
};
const handleRemovalSuccess = async () => {
await fetchTOTPFactors();
};
const copySecret = () => {
navigator.clipboard.writeText(secret);
handleInfo('Copied', 'Secret key copied to clipboard');
};
const cancelEnrollment = () => {
setEnrolling(false);
setQrCode('');
setSecret('');
setFactorId('');
setVerificationCode('');
};
const activeFactor = factors.find(f => f.status === 'verified');
if (enrolling) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
Set Up Authenticator App
</CardTitle>
<CardDescription>
Scan the QR code with your authenticator app, then enter the verification code below.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* QR Code */}
<div className="flex justify-center">
<div className="p-4 bg-white rounded-lg border">
<img src={qrCode} alt="TOTP QR Code" className="w-48 h-48" />
</div>
</div>
{/* Manual Entry */}
<div className="space-y-2">
<Label>Can't scan? Enter this key manually:</Label>
<div className="flex items-center gap-2">
<Input
value={secret}
readOnly
type={showSecret ? 'text' : 'password'}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="sm"
onClick={() => setShowSecret(!showSecret)}
>
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={copySecret}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{/* Verification */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="verificationCode">Enter verification code from your app:</Label>
<Input
id="verificationCode"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
onPaste={(e) => e.preventDefault()}
placeholder="000000"
maxLength={6}
className="text-center text-lg tracking-widest font-mono"
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={cancelEnrollment}>
Cancel
</Button>
<Button onClick={verifyAndEnable} disabled={loading || !verificationCode.trim()}>
{loading ? 'Verifying...' : 'Enable Multi-Factor Authentication'}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardDescription>
Add an extra layer of security to your account with Multi-Factor Authentication.
</CardDescription>
</CardHeader>
<CardContent>
{activeFactor ? (
<div className="space-y-4">
<Alert>
<Shield className="w-4 h-4" />
<AlertDescription>
Multi-Factor Authentication is enabled for your account. You'll be prompted for a code from your authenticator app when signing in.
</AlertDescription>
</Alert>
{/* Phase 2: Warning for role-required users */}
{requiresMFA && (
<Alert variant="default" className="border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<AlertDescription className="text-amber-800 dark:text-amber-200">
Your role requires Multi-Factor Authentication. You cannot disable it.
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-green-50 dark:bg-green-950 rounded-full flex items-center justify-center">
<Smartphone className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="font-medium">{activeFactor.friendly_name || 'Authenticator App'}</p>
<p className="text-sm text-muted-foreground">
Enabled {new Date(activeFactor.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300">
Active
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => setShowRemovalDialog(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Disable
</Button>
</div>
</div>
<MFARemovalDialog
open={showRemovalDialog}
onOpenChange={setShowRemovalDialog}
factorId={activeFactor.id}
onSuccess={handleRemovalSuccess}
/>
</div>
) : (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
<Smartphone className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Authenticator App</p>
<p className="text-sm text-muted-foreground">
Use an authenticator app to generate verification codes
</p>
</div>
</div>
<Button onClick={startEnrollment} disabled={loading}>
{loading ? 'Setting up...' : 'Set Up'}
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { Callout, CalloutDescription } from '@/components/ui/callout';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface TurnstileCaptchaProps {
onSuccess: (token: string) => void;
onError?: (error: string) => void;
onExpire?: () => void;
siteKey?: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact';
className?: string;
}
export function TurnstileCaptcha({
onSuccess,
onError,
onExpire,
siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY,
theme = 'auto',
size = 'normal',
className = ''
}: TurnstileCaptchaProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [key, setKey] = useState(0);
const turnstileRef = useRef(null);
const handleSuccess = (token: string) => {
setError(null);
setLoading(false);
onSuccess(token);
};
const handleError = (errorCode: string) => {
setLoading(false);
const errorMessage = getErrorMessage(errorCode);
setError(errorMessage);
onError?.(errorMessage);
};
const handleExpire = () => {
setError('CAPTCHA expired. Please try again.');
onExpire?.();
};
const handleLoad = () => {
setLoading(false);
setError(null);
};
const resetCaptcha = () => {
setKey(prev => prev + 1);
setError(null);
setLoading(true);
};
const getErrorMessage = (errorCode: string): string => {
switch (errorCode) {
case 'network-error':
return 'Network error. Please check your connection and try again.';
case 'timeout':
return 'CAPTCHA timed out. Please try again.';
case 'invalid-sitekey':
return 'Invalid site configuration. Please contact support.';
case 'token-already-spent':
return 'CAPTCHA token already used. Please refresh and try again.';
default:
return 'CAPTCHA verification failed. Please try again.';
}
};
// Monitor for initialization failures
useEffect(() => {
if (loading) {
const timeout = setTimeout(() => {
setLoading(false);
}, 5000); // 5 second timeout
return () => clearTimeout(timeout);
}
}, [loading]);
if (!siteKey) {
return (
<Callout variant="warning">
<AlertCircle className="h-4 w-4" />
<CalloutDescription>
CAPTCHA is not configured. Please set VITE_TURNSTILE_SITE_KEY environment variable.
</CalloutDescription>
</Callout>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="flex flex-col items-center">
{loading && (
<div className="w-[300px] h-[65px] flex items-center justify-center border border-dashed border-muted-foreground/30 rounded-lg bg-muted/10 animate-pulse">
<span className="text-xs text-muted-foreground">Loading CAPTCHA...</span>
</div>
)}
<div
className="transition-opacity duration-100"
style={{
display: loading ? 'none' : 'block',
opacity: loading ? 0 : 1
}}
>
<Turnstile
key={key}
ref={turnstileRef}
siteKey={siteKey}
onSuccess={handleSuccess}
onError={handleError}
onExpire={handleExpire}
onLoad={handleLoad}
options={{
theme,
size,
execution: 'render',
appearance: 'always',
retry: 'auto'
}}
/>
</div>
</div>
{error && (
<Callout variant="destructive">
<AlertCircle className="h-4 w-4" />
<CalloutDescription className="flex items-center justify-between">
<span>{error}</span>
<Button
variant="outline"
size="sm"
onClick={resetCaptcha}
className="ml-2 h-6 px-2 text-xs"
>
<RefreshCw className="w-3 h-3 mr-1" />
Retry
</Button>
</CalloutDescription>
</Callout>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Eye, Calendar } from 'lucide-react';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { formatDistanceToNow } from 'date-fns';
interface BlogPostCardProps {
slug: string;
title: string;
content: string;
featuredImageId?: string;
author: {
username: string;
displayName?: string;
avatarUrl?: string;
};
publishedAt: string;
viewCount: number;
}
export function BlogPostCard({
slug,
title,
content,
featuredImageId,
author,
publishedAt,
viewCount,
}: BlogPostCardProps) {
const excerpt = content.substring(0, 150) + (content.length > 150 ? '...' : '');
return (
<Link to={`/blog/${slug}`}>
<Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300">
<div className="aspect-[3/2] overflow-hidden bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative">
{featuredImageId ? (
<>
<img
src={getCloudflareImageUrl(featuredImageId, 'public')}
alt={title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl animate-pulse" />
<div className="relative w-20 h-20 rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 flex items-center justify-center border border-primary/20">
<span className="text-4xl opacity-80">📝</span>
</div>
</div>
</div>
)}
</div>
<div className="p-3 space-y-2 border-t border-border/30">
<h3 className="text-base font-bold line-clamp-2 group-hover:text-primary transition-all duration-300 group-hover:drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]">
{title}
</h3>
<p className="text-sm text-muted-foreground line-clamp-3">
{excerpt}
</p>
<div className="flex items-center justify-between pt-3 border-t">
<div className="flex items-center gap-2">
<Avatar className="w-6 h-6">
<AvatarImage src={author.avatarUrl} />
<AvatarFallback>
{author.displayName?.[0] || author.username[0]}
</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground">
{author.displayName || author.username}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(publishedAt), { addSuffix: true })}
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{viewCount}
</div>
</div>
</div>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,75 @@
import ReactMarkdown from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { cn } from '@/lib/utils';
interface MarkdownRendererProps {
content: string;
className?: string;
}
// Custom sanitization schema with enhanced security
const customSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: [
...(defaultSchema.attributes?.a || []),
['rel', 'noopener', 'noreferrer'],
['target', '_blank']
],
img: [
...(defaultSchema.attributes?.img || []),
['loading', 'lazy'],
['referrerpolicy', 'no-referrer']
]
}
};
/**
* Secure Markdown Renderer with XSS Protection
*
* Security features:
* - Sanitizes all user-generated HTML using rehype-sanitize
* - Strips all raw HTML tags (skipHtml=true)
* - Enforces noopener noreferrer on all links
* - Adds lazy loading to images
* - Sets referrer policy to prevent data leakage
*
* @see docs/SECURITY.md for markdown security policy
*/
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
return (
<div
className={cn(
'prose dark:prose-invert max-w-none',
'prose-headings:font-bold prose-headings:tracking-tight',
'prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl',
'prose-p:text-base prose-p:leading-relaxed',
'prose-a:text-primary prose-a:no-underline hover:prose-a:underline',
'prose-strong:text-foreground prose-strong:font-semibold',
'prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm',
'prose-pre:bg-muted prose-pre:border prose-pre:border-border',
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:italic',
'prose-img:rounded-lg prose-img:shadow-lg',
'prose-hr:border-border',
'prose-ul:list-disc prose-ol:list-decimal',
className
)}
>
<ReactMarkdown
rehypePlugins={[[rehypeSanitize, customSchema]]}
skipHtml={true}
components={{
a: ({node, ...props}) => (
<a {...props} rel="noopener noreferrer" target="_blank" />
),
img: ({node, ...props}) => (
<img {...props} loading="lazy" referrerPolicy="no-referrer" />
)
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,80 @@
/**
* LazyImage Component
* Implements lazy loading for images using Intersection Observer
* Only loads images when they're scrolled into view
*/
import { useState, useEffect, useRef } from 'react';
interface LazyImageProps {
src: string;
alt: string;
className?: string;
onLoad?: () => void;
onError?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void;
}
export function LazyImage({
src,
alt,
className = '',
onLoad,
onError
}: LazyImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const [hasError, setHasError] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{
rootMargin: '100px', // Start loading 100px before visible
threshold: 0.01,
}
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
const handleLoad = () => {
setIsLoaded(true);
onLoad?.();
};
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
setHasError(true);
onError?.(e);
};
return (
<div ref={imgRef} className={`relative ${className}`}>
{!isInView || hasError ? (
// Loading skeleton or error state
<div className="w-full h-full bg-muted animate-pulse rounded" />
) : (
<img
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
/>
)}
</div>
);
}
LazyImage.displayName = 'LazyImage';

View File

@@ -0,0 +1,114 @@
import { ReactNode } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, AlertCircle } from 'lucide-react';
interface LoadingGateProps {
/** Whether data is still loading */
isLoading: boolean;
/** Optional error to display */
error?: Error | null;
/** Content to render when loaded */
children: ReactNode;
/** Loading variant */
variant?: 'skeleton' | 'spinner' | 'card';
/** Number of skeleton items (for skeleton variant) */
skeletonCount?: number;
/** Custom loading message */
loadingMessage?: string;
/** Custom error message */
errorMessage?: string;
}
/**
* Reusable loading and error state wrapper
*
* Handles common loading patterns:
* - Skeleton loaders
* - Spinner with message
* - Card-based loading states
* - Error display
*
* @example
* ```tsx
* <LoadingGate isLoading={loading} error={error} variant="skeleton" skeletonCount={3}>
* <YourContent />
* </LoadingGate>
* ```
*/
export function LoadingGate({
isLoading,
error,
children,
variant = 'skeleton',
skeletonCount = 3,
loadingMessage = 'Loading...',
errorMessage,
}: LoadingGateProps) {
// Error state
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{errorMessage || error.message || 'An unexpected error occurred'}
</AlertDescription>
</Alert>
);
}
// Loading state
if (isLoading) {
switch (variant) {
case 'spinner':
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
</div>
);
case 'card':
return (
<Card>
<CardContent className="p-6 space-y-3">
{Array.from({ length: skeletonCount }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3 border rounded-lg">
<Skeleton className="h-10 w-10 rounded-full" />
<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-8 w-24" />
</div>
))}
</CardContent>
</Card>
);
case 'skeleton':
default:
return (
<div className="space-y-3">
{Array.from({ length: skeletonCount }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
);
}
}
// Loaded state
return <>{children}</>;
}

View File

@@ -0,0 +1,87 @@
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
}
export function Pagination({ currentPage, totalPages, onPageChange, isLoading }: PaginationProps) {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const showPages = 5;
if (totalPages <= showPages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-2 mt-8">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{getPageNumbers().map((page, idx) => (
typeof page === 'number' ? (
<Button
key={idx}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(page)}
disabled={isLoading}
className="min-w-[40px]"
>
{page}
</Button>
) : (
<span key={idx} className="px-2 text-muted-foreground">
{page}
</span>
)
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages || isLoading}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,90 @@
/**
* PhotoGrid Component
* Reusable photo grid display with modal support
*/
import { memo } from 'react';
import { Eye, AlertCircle } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import type { PhotoItem } from '@/types/photos';
import { generatePhotoAlt } from '@/lib/photoHelpers';
import { LazyImage } from '@/components/common/LazyImage';
interface PhotoGridProps {
photos: PhotoItem[];
onPhotoClick?: (photos: PhotoItem[], index: number) => void;
maxDisplay?: number;
className?: string;
}
export const PhotoGrid = memo(({
photos,
onPhotoClick,
maxDisplay,
className = ''
}: PhotoGridProps) => {
const isMobile = useIsMobile();
const defaultMaxDisplay = isMobile ? 2 : 3;
const maxToShow = maxDisplay ?? defaultMaxDisplay;
const displayPhotos = photos.slice(0, maxToShow);
const remainingCount = Math.max(0, photos.length - maxToShow);
if (photos.length === 0) {
return (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
No photos available
</div>
);
}
return (
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'} ${className}`}>
{displayPhotos.map((photo, index) => (
<div
key={photo.id}
className="relative cursor-pointer group overflow-hidden rounded-md border bg-muted/30 h-32"
onClick={() => onPhotoClick?.(photos, index)}
>
<LazyImage
src={photo.url}
alt={generatePhotoAlt(photo)}
className="w-full h-32"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const errorDiv = document.createElement('div');
errorDiv.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs p-2';
const icon = document.createElement('div');
icon.textContent = '⚠️';
icon.className = 'text-lg mb-1';
const text = document.createElement('div');
text.textContent = 'Failed to load';
errorDiv.appendChild(icon);
errorDiv.appendChild(text);
parent.appendChild(errorDiv);
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<Eye className="w-5 h-5" />
</div>
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs p-1 truncate">
{photo.caption}
</div>
)}
</div>
))}
{remainingCount > 0 && (
<div className="flex items-center justify-center bg-muted rounded-md text-sm text-muted-foreground font-medium">
+{remainingCount} more
</div>
)}
</div>
);
});
PhotoGrid.displayName = 'PhotoGrid';

View File

@@ -0,0 +1,156 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { User, Shield, ShieldCheck, Crown } from 'lucide-react';
import { getRoleLabel } from '@/lib/moderation/constants';
interface ProfileBadgeProps {
/** Username to display */
username?: string;
/** Display name (fallback to username) */
displayName?: string;
/** Avatar image URL */
avatarUrl?: string;
/** User role */
role?: 'admin' | 'moderator' | 'user' | 'superuser';
/** Show role badge */
showRole?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Whether to show as a link */
clickable?: boolean;
/** Custom click handler */
onClick?: () => void;
}
const sizeClasses = {
sm: {
avatar: 'h-6 w-6',
text: 'text-xs',
badge: 'h-4 text-[10px] px-1',
},
md: {
avatar: 'h-8 w-8',
text: 'text-sm',
badge: 'h-5 text-xs px-1.5',
},
lg: {
avatar: 'h-10 w-10',
text: 'text-base',
badge: 'h-6 text-sm px-2',
},
};
const roleIcons = {
superuser: Crown,
admin: ShieldCheck,
moderator: Shield,
user: User,
};
const roleColors = {
superuser: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
admin: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
moderator: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
user: 'bg-muted text-muted-foreground border-border',
};
/**
* Reusable user profile badge component
*
* Displays user avatar, name, and optional role badge
* Used consistently across moderation queue and admin panels
*
* @example
* ```tsx
* <ProfileBadge
* username="johndoe"
* displayName="John Doe"
* avatarUrl="/avatars/john.jpg"
* role="moderator"
* showRole
* size="md"
* />
* ```
*/
export function ProfileBadge({
username,
displayName,
avatarUrl,
role = 'user',
showRole = false,
size = 'md',
clickable = false,
onClick,
}: ProfileBadgeProps) {
const sizes = sizeClasses[size];
const name = displayName || username || 'Anonymous';
const initials = name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
const RoleIcon = roleIcons[role];
const content = (
<div
className={`flex items-center gap-2 ${clickable ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
onClick={onClick}
>
<Avatar className={sizes.avatar}>
<AvatarImage src={avatarUrl} alt={name} />
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className={`font-medium truncate ${sizes.text}`}>
{name}
</span>
{username && displayName && (
<span className="text-xs text-muted-foreground truncate">
@{username}
</span>
)}
</div>
{showRole && role !== 'user' && (
<Badge
variant="outline"
className={`${sizes.badge} ${roleColors[role]} flex items-center gap-1`}
>
<RoleIcon className="h-3 w-3" />
<span className="hidden sm:inline">{getRoleLabel(role)}</span>
</Badge>
)}
</div>
);
if (showRole && role !== 'user') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{content}
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{getRoleLabel(role)}
{username && ` • @${username}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return content;
}

View File

@@ -0,0 +1,122 @@
import { ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
interface SortControlsProps<T extends string = string> {
/** Current sort field */
sortField: T;
/** Current sort direction */
sortDirection: 'asc' | 'desc';
/** Available sort fields with labels */
sortFields: Record<T, string>;
/** Handler for field change */
onFieldChange: (field: T) => void;
/** Handler for direction toggle */
onDirectionToggle: () => void;
/** Whether component is in mobile mode */
isMobile?: boolean;
/** Whether data is loading */
isLoading?: boolean;
/** Optional label for the sort selector */
label?: string;
}
/**
* Generic reusable sort controls component
*
* Provides consistent sorting UI across the application:
* - Field selector with custom labels
* - Direction toggle (asc/desc)
* - Mobile-responsive layout
* - Loading states
*
* @example
* ```tsx
* <SortControls
* sortField={sortConfig.field}
* sortDirection={sortConfig.direction}
* sortFields={{
* created_at: 'Date Created',
* name: 'Name',
* status: 'Status'
* }}
* onFieldChange={(field) => setSortConfig({ ...sortConfig, field })}
* onDirectionToggle={() => setSortConfig({
* ...sortConfig,
* direction: sortConfig.direction === 'asc' ? 'desc' : 'asc'
* })}
* isMobile={isMobile}
* />
* ```
*/
export function SortControls<T extends string = string>({
sortField,
sortDirection,
sortFields,
onFieldChange,
onDirectionToggle,
isMobile = false,
isLoading = false,
label = 'Sort By',
}: SortControlsProps<T>) {
const DirectionIcon = sortDirection === 'asc' ? ArrowUp : ArrowDown;
return (
<div className={`flex gap-2 ${isMobile ? 'flex-col' : 'items-end'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[160px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'} flex items-center gap-2`}>
{label}
{isLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
</Label>
<Select
value={sortField}
onValueChange={onFieldChange}
disabled={isLoading}
>
<SelectTrigger className={isMobile ? "h-10" : ""} disabled={isLoading}>
<SelectValue>
{sortFields[sortField]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(sortFields).map(([field, label]) => (
<SelectItem key={field} value={field}>
{label as string}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={isMobile ? "" : "pb-[2px]"}>
<Button
variant="outline"
size={isMobile ? "default" : "icon"}
onClick={onDirectionToggle}
disabled={isLoading}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : 'h-10 w-10'}`}
title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<DirectionIcon className="w-4 h-4" />
)}
{isMobile && (
<span className="capitalize">
{isLoading ? 'Loading...' : `${sortDirection}ending`}
</span>
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
// Common reusable components barrel exports
export { LoadingGate } from './LoadingGate';
export { ProfileBadge } from './ProfileBadge';
export { SortControls } from './SortControls';

View File

@@ -0,0 +1,23 @@
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
interface DesignerPhotoGalleryProps {
designerId: string;
designerName: string;
}
/**
* Wrapper for designer photo galleries
* Uses the generic EntityPhotoGallery component internally
*/
export function DesignerPhotoGallery({
designerId,
designerName
}: DesignerPhotoGalleryProps) {
return (
<EntityPhotoGallery
entityId={designerId}
entityType="designer"
entityName={designerName}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
interface ManufacturerPhotoGalleryProps {
manufacturerId: string;
manufacturerName: string;
}
/**
* Wrapper for manufacturer photo galleries
* Uses the generic EntityPhotoGallery component internally
*/
export function ManufacturerPhotoGallery({
manufacturerId,
manufacturerName
}: ManufacturerPhotoGalleryProps) {
return (
<EntityPhotoGallery
entityId={manufacturerId}
entityType="manufacturer"
entityName={manufacturerName}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
interface OperatorPhotoGalleryProps {
operatorId: string;
operatorName: string;
}
/**
* Wrapper for operator photo galleries
* Uses the generic EntityPhotoGallery component internally
*/
export function OperatorPhotoGallery({
operatorId,
operatorName
}: OperatorPhotoGalleryProps) {
return (
<EntityPhotoGallery
entityId={operatorId}
entityType="operator"
entityName={operatorName}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
interface PropertyOwnerPhotoGalleryProps {
propertyOwnerId: string;
propertyOwnerName: string;
}
/**
* Wrapper for property owner photo galleries
* Uses the generic EntityPhotoGallery component internally
*/
export function PropertyOwnerPhotoGallery({
propertyOwnerId,
propertyOwnerName
}: PropertyOwnerPhotoGalleryProps) {
return (
<EntityPhotoGallery
entityId={propertyOwnerId}
entityType="property_owner"
entityName={propertyOwnerName}
/>
);
}

View File

@@ -0,0 +1,104 @@
import { Link } from 'react-router-dom';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
export function ContactFAQ() {
const faqs = [
{
question: 'How do I submit a new park or ride?',
answer: (
<>
You can submit new parks and rides through our submission system. Simply navigate to the{' '}
<Link to="/parks" className="text-primary hover:underline">
Parks
</Link>{' '}
or{' '}
<Link to="/rides" className="text-primary hover:underline">
Rides
</Link>{' '}
page and click the "Add" button. All submissions go through our moderation queue to ensure quality and accuracy.
</>
),
},
{
question: 'How long does moderation take?',
answer:
'Most submissions are reviewed within 24-48 hours. Complex submissions or those requiring additional verification may take slightly longer. You can track the status of your submissions in your profile.',
},
{
question: 'Can I edit my submissions?',
answer:
'Yes! You can edit any information you\'ve submitted. All edits also go through moderation to maintain data quality. We track all changes in our version history system.',
},
{
question: 'How do I report incorrect information?',
answer: (
<>
If you notice incorrect information, you can either submit an edit with the correct data or contact us using the form above with category "Report an Issue". Please include the page URL and describe what needs to be corrected.
</>
),
},
{
question: 'What if I forgot my password?',
answer: (
<>
Use the "Forgot Password" link on the{' '}
<Link to="/auth" className="text-primary hover:underline">
login page
</Link>
. We'll send you a password reset link via email. If you don't receive it, check your spam folder or contact us for assistance.
</>
),
},
{
question: 'How do I delete my account?',
answer: (
<>
You can request account deletion from your{' '}
<Link to="/settings" className="text-primary hover:underline">
Settings page
</Link>
. For security, we require confirmation before processing deletion requests. Please note that some anonymized data (like submissions) may be retained for database integrity.
</>
),
},
{
question: 'Do you have a community Discord or forum?',
answer:
'We\'re planning to launch community features soon! For now, you can connect with other enthusiasts through reviews and comments on park and ride pages.',
},
{
question: 'Can I contribute photos?',
answer:
'Absolutely! You can upload photos to any park or ride page. Photos go through moderation and must follow our content guidelines. High-quality photos are greatly appreciated!',
},
];
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold mb-2">Frequently Asked Questions</h2>
<p className="text-muted-foreground">
Find answers to common questions. If you don't see what you're looking for, feel free to contact us.
</p>
</div>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,316 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
import { supabase } from '@/lib/supabaseClient';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { contactFormSchema, contactCategories, type ContactFormData } from '@/lib/contactValidation';
import { useAuth } from '@/hooks/useAuth';
export function ContactForm() {
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>('');
const [captchaKey, setCaptchaKey] = useState(0);
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
// Fetch user profile if logged in
useEffect(() => {
const fetchUserProfile = async () => {
if (user) {
setUserEmail(user.email || '');
const { data: profile } = await supabase
.from('profiles')
.select('display_name, username')
.eq('user_id', user.id)
.single();
if (profile) {
setUserName(profile.display_name || profile.username || '');
}
}
};
fetchUserProfile();
}, [user]);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: userName,
email: userEmail,
subject: '',
category: 'general',
message: '',
captchaToken: '',
},
});
// Update form when user data loads
useEffect(() => {
if (userName) {
setValue('name', userName);
}
if (userEmail) {
setValue('email', userEmail);
}
}, [userName, userEmail, setValue]);
const onCaptchaSuccess = (token: string) => {
setCaptchaToken(token);
setValue('captchaToken', token);
};
const onCaptchaError = () => {
setCaptchaToken('');
setValue('captchaToken', '');
handleError(
new Error('CAPTCHA verification failed'),
{ action: 'verify_captcha' }
);
};
const onCaptchaExpire = () => {
setCaptchaToken('');
setValue('captchaToken', '');
};
const onSubmit = async (data: ContactFormData) => {
if (!captchaToken) {
handleError(
new Error('Please complete the CAPTCHA'),
{ action: 'submit_contact_form' }
);
return;
}
setIsSubmitting(true);
try {
logger.info('Submitting contact form', { category: data.category });
const { data: result, error } = await supabase.functions.invoke(
'send-contact-message',
{
body: {
name: data.name,
email: data.email,
subject: data.subject,
message: data.message,
category: data.category,
captchaToken: data.captchaToken,
},
}
);
if (error) {
throw error;
}
handleSuccess(
'Message Sent!',
"Thank you for contacting us. We'll respond within 24-48 hours."
);
// Reset form
reset({
name: userName,
email: userEmail,
subject: '',
category: 'general',
message: '',
captchaToken: '',
});
// Reset CAPTCHA
setCaptchaToken('');
setCaptchaKey((prev) => prev + 1);
} catch (error) {
handleError(error, {
action: 'submit_contact_form',
metadata: { category: data.category },
});
// Reset CAPTCHA on error
setCaptchaToken('');
setCaptchaKey((prev) => prev + 1);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Your name"
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="your.email@example.com"
disabled={isSubmitting}
className={errors.email ? 'border-destructive' : ''}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
{/* Category */}
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
value={watch('category')}
onValueChange={(value) =>
setValue('category', value as ContactFormData['category'])
}
disabled={isSubmitting}
>
<SelectTrigger className={errors.category ? 'border-destructive' : ''}>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{contactCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category && (
<p className="text-sm text-destructive">{errors.category.message}</p>
)}
</div>
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">Subject *</Label>
<Input
id="subject"
{...register('subject')}
placeholder="Brief description of your inquiry"
disabled={isSubmitting}
className={errors.subject ? 'border-destructive' : ''}
/>
{errors.subject && (
<p className="text-sm text-destructive">{errors.subject.message}</p>
)}
</div>
{/* Message */}
<div className="space-y-2">
<Label htmlFor="message">Message * (minimum 20 characters)</Label>
<Textarea
id="message"
{...register('message')}
placeholder="Please provide details about your inquiry (minimum 20 characters)..."
rows={6}
disabled={isSubmitting}
className={errors.message ? 'border-destructive' : ''}
/>
<div className="flex justify-between items-center">
{errors.message && (
<p className="text-sm text-destructive">{errors.message.message}</p>
)}
<p className={`text-sm ml-auto ${
(watch('message')?.length || 0) < 20
? 'text-destructive font-medium'
: 'text-muted-foreground'
}`}>
{watch('message')?.length || 0} / 2000
{(watch('message')?.length || 0) < 20 &&
` (${20 - (watch('message')?.length || 0)} more needed)`
}
</p>
</div>
</div>
{/* CAPTCHA */}
<div className="space-y-2">
<TurnstileCaptcha
key={captchaKey}
onSuccess={onCaptchaSuccess}
onError={onCaptchaError}
onExpire={onCaptchaExpire}
theme="auto"
size="normal"
/>
{errors.captchaToken && (
<p className="text-sm text-destructive">
{errors.captchaToken.message}
</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
size="lg"
disabled={isSubmitting || !captchaToken || (watch('message')?.length || 0) < 20}
className="w-full"
title={
!captchaToken
? 'Please complete the CAPTCHA'
: (watch('message')?.length || 0) < 20
? `Message must be at least 20 characters (currently ${watch('message')?.length || 0})`
: ''
}
>
{isSubmitting ? (
<>
<span className="animate-spin mr-2"></span>
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Message
</>
)}
</Button>
<p className="text-sm text-muted-foreground text-center">
We typically respond within 24-48 hours
</p>
</form>
);
}

View File

@@ -0,0 +1,28 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
interface ContactInfoCardProps {
icon: LucideIcon;
title: string;
description: string;
content: React.ReactNode;
}
export function ContactInfoCard({ icon: Icon, title, description, content }: ContactInfoCardProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="text-sm">{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>{content}</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,128 @@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Star, MapPin, Ruler, FerrisWheel } from 'lucide-react';
import { CompanyWithStats } from '@/types/database';
import { useNavigate } from 'react-router-dom';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
interface DesignerCardProps {
company: CompanyWithStats;
}
export function DesignerCard({ company }: DesignerCardProps) {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/designers/${company.slug}/`);
};
const getCompanyIcon = () => {
return <Ruler className="w-8 h-8 text-muted-foreground" />;
};
return (
<Card
className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300"
onClick={handleClick}
>
{/* Header Image/Logo Section */}
<div className="relative aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center overflow-hidden">
{/* Background Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
{/* Company Type Badge */}
<div className="absolute top-3 right-3">
<Badge variant="secondary" className="text-xs bg-background/80 backdrop-blur-sm border border-border/50">
Designer
</Badge>
</div>
{/* Logo or Icon */}
<div className="relative z-10 flex items-center justify-center">
{company.logo_url ? (
<div className="w-12 h-12 md:w-16 md:h-16 bg-background/90 rounded-xl overflow-hidden shadow-lg backdrop-blur-sm border border-border/50">
<img
src={company.logo_url}
alt={`${company.name} logo`}
className="w-full h-full object-contain p-2"
loading="lazy"
/>
</div>
) : (
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl animate-pulse" />
<div className="relative w-16 h-16 rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 flex items-center justify-center border border-primary/20">
<Ruler className="w-8 h-8 text-primary/70" />
</div>
</div>
)}
</div>
</div>
<CardContent className="p-2.5 space-y-1.5 border-t border-border/30">
{/* Company Name */}
<div className="space-y-0.5 min-w-0">
<h3 className="font-bold text-base group-hover:text-primary transition-all duration-300 line-clamp-2 break-words group-hover:drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]">
{company.name}
</h3>
</div>
{/* Company Details */}
<div className="space-y-1 text-sm">
{/* Founded Year */}
{company.founded_year && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Founded:</span>
<span className="font-medium">{company.founded_year}</span>
</div>
)}
{/* Location */}
{company.headquarters_location && (
<div className="flex items-center gap-1 min-w-0">
<MapPin className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
<span className="text-muted-foreground truncate">{company.headquarters_location}</span>
</div>
)}
</div>
{/* Stats Display */}
<div className="flex items-center justify-between text-sm">
<div className="flex flex-wrap gap-3 gap-y-1">
{company.ride_count && company.ride_count > 0 && (
<div className="flex items-center gap-1">
<FerrisWheel className="w-4 h-4 text-primary/70 flex-shrink-0" />
<span className="font-medium">{company.ride_count}</span>
<span className="text-muted-foreground">designs</span>
</div>
)}
{company.coaster_count && company.coaster_count > 0 && (
<div className="flex items-center gap-1">
<span className="font-medium">{company.coaster_count}</span>
<span className="text-muted-foreground">coasters</span>
</div>
)}
{company.model_count && company.model_count > 0 && (
<div className="flex items-center gap-1">
<span className="font-medium">{company.model_count}</span>
<span className="text-muted-foreground">concepts</span>
</div>
)}
</div>
{company.average_rating && company.average_rating > 0 && (
<div className="inline-flex items-center gap-1">
<Star className="w-4 h-4 fill-yellow-500 text-yellow-500" />
<span className="font-semibold">{company.average_rating.toFixed(1)}</span>
{company.review_count && company.review_count > 0 && (
<span className="text-muted-foreground">({company.review_count})</span>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,194 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RotateCcw } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { FilterRangeSlider } from '@/components/filters/FilterRangeSlider';
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
import { FilterSection } from '@/components/filters/FilterSection';
import { FilterMultiSelectCombobox } from '@/components/filters/FilterMultiSelectCombobox';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { Company } from '@/types/database';
export interface DesignerFilterState {
personType: string;
countries: string[];
minRating: number;
maxRating: number;
minReviewCount: number;
maxReviewCount: number;
foundedDateFrom: Date | null;
foundedDateTo: Date | null;
hasRating: boolean;
hasFoundedDate: boolean;
isIndividual: boolean;
}
export const defaultDesignerFilters: DesignerFilterState = {
personType: 'all',
countries: [],
minRating: 0,
maxRating: 5,
minReviewCount: 0,
maxReviewCount: 1000,
foundedDateFrom: null,
foundedDateTo: null,
hasRating: false,
hasFoundedDate: false,
isIndividual: false,
};
interface DesignerFiltersProps {
filters: DesignerFilterState;
onFiltersChange: (filters: DesignerFilterState) => void;
designers: Company[];
}
export function DesignerFilters({ filters, onFiltersChange, designers }: DesignerFiltersProps) {
const { data: locations } = useQuery({
queryKey: ['filter-locations'],
queryFn: async () => {
const { data } = await supabase
.from('locations')
.select('country')
.not('country', 'is', null);
return data || [];
},
staleTime: 5 * 60 * 1000,
});
const countryOptions: MultiSelectOption[] = useMemo(() => {
const countries = new Set(locations?.map(l => l.country).filter(Boolean) || []);
return Array.from(countries).sort().map(c => ({ label: c, value: c }));
}, [locations]);
const maxReviewCount = useMemo(() => {
return Math.max(...designers.map(d => d.review_count || 0), 1000);
}, [designers]);
const resetFilters = () => {
onFiltersChange({ ...defaultDesignerFilters, maxReviewCount });
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Filter Designers</h3>
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="w-4 h-4 mr-2" />
Reset
</Button>
</div>
<FilterSection title="Basic Filters">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label>Entity Type</Label>
<Select
value={filters.personType}
onValueChange={(value) => onFiltersChange({ ...filters, personType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="company">Companies</SelectItem>
<SelectItem value="individual">Individuals</SelectItem>
</SelectContent>
</Select>
</div>
<FilterMultiSelectCombobox
label="Headquarters Country"
options={countryOptions}
value={filters.countries}
onChange={(value) => onFiltersChange({ ...filters, countries: value })}
placeholder="Select countries"
/>
<FilterDateRangePicker
label="Founded Date"
fromDate={filters.foundedDateFrom}
toDate={filters.foundedDateTo}
onFromChange={(date) => onFiltersChange({ ...filters, foundedDateFrom: date || null })}
onToChange={(date) => onFiltersChange({ ...filters, foundedDateTo: date || null })}
fromPlaceholder="From year"
toPlaceholder="To year"
/>
</div>
</FilterSection>
<Separator />
<FilterSection title="Statistics">
<div className="grid grid-cols-1 gap-4">
<FilterRangeSlider
label="Rating"
value={[filters.minRating, filters.maxRating]}
onChange={([min, max]) => onFiltersChange({ ...filters, minRating: min, maxRating: max })}
min={0}
max={5}
step={0.1}
formatValue={(v) => `${v.toFixed(1)} stars`}
/>
<FilterRangeSlider
label="Review Count"
value={[filters.minReviewCount, filters.maxReviewCount]}
onChange={([min, max]) => onFiltersChange({ ...filters, minReviewCount: min, maxReviewCount: max })}
min={0}
max={maxReviewCount}
step={10}
/>
</div>
</FilterSection>
<Separator />
<FilterSection title="Other Options">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="hasRating"
checked={filters.hasRating}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasRating: checked as boolean })
}
/>
<Label htmlFor="hasRating" className="cursor-pointer">
Has rating
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasFoundedDate"
checked={filters.hasFoundedDate}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasFoundedDate: checked as boolean })
}
/>
<Label htmlFor="hasFoundedDate" className="cursor-pointer">
Has founded date
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isIndividual"
checked={filters.isIndividual}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, isIndividual: checked as boolean })
}
/>
<Label htmlFor="isIndividual" className="cursor-pointer">
Individual designers only
</Label>
</div>
</div>
</FilterSection>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { MapPin, Star, Ruler, Calendar, Palette } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Company } from '@/types/database';
import { cn } from '@/lib/utils';
interface DesignerListViewProps {
designers: Company[];
onDesignerClick: (designer: Company) => void;
}
export function DesignerListView({ designers, onDesignerClick }: DesignerListViewProps) {
return (
<div className="space-y-3">
{designers.map((designer, index) => (
<Card
key={designer.id}
className={cn(
"group overflow-hidden cursor-pointer",
"border-border/40 bg-gradient-to-br from-card via-card/95 to-card/90",
"hover:border-primary/30 hover:shadow-2xl hover:shadow-primary/5",
"transition-all duration-500 animate-fade-in-up"
)}
style={{ animationDelay: `${index * 50}ms` }}
onClick={() => onDesignerClick(designer)}
>
<CardContent className="p-0">
<div className="flex flex-col sm:flex-row">
{/* Logo */}
<div className="w-full h-32 sm:w-32 sm:h-auto md:w-40 flex-shrink-0 self-stretch relative overflow-hidden bg-gradient-to-br from-muted/50 to-muted/30">
{designer.logo_url ? (
<img
src={designer.logo_url}
alt={designer.name}
className="w-full h-full object-contain p-4 group-hover:scale-110 transition-transform duration-700"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Ruler className="w-12 h-12 text-muted-foreground/40" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 p-4 md:p-6 flex flex-col justify-between min-h-[128px]">
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg md:text-xl group-hover:text-primary transition-colors duration-300 line-clamp-1 mb-1.5">
{designer.name}
</h3>
{designer.headquarters_location && (
<div className="flex items-center text-sm text-muted-foreground group-hover:text-foreground transition-colors duration-300">
<MapPin className="w-3.5 h-3.5 mr-1.5 flex-shrink-0" />
<span className="line-clamp-1">{designer.headquarters_location}</span>
</div>
)}
</div>
{/* Rating */}
{designer.average_rating && designer.average_rating > 0 && (
<div className="flex items-center gap-1.5 ml-4 flex-shrink-0 bg-yellow-400/10 px-3 py-1.5 rounded-full group-hover:bg-yellow-400/20 transition-colors duration-300">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="font-semibold text-foreground">{designer.average_rating.toFixed(1)}</span>
{designer.review_count && designer.review_count > 0 && (
<span className="text-xs text-muted-foreground">({designer.review_count})</span>
)}
</div>
)}
</div>
{/* Description */}
{designer.description && (
<p className="text-sm text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300 line-clamp-2">
{designer.description}
</p>
)}
{/* Tags */}
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs backdrop-blur-sm border-primary/20 group-hover:border-primary/40 transition-colors duration-300">
<Palette className="w-3 h-3 mr-1" />
Designer
</Badge>
{designer.founded_year && (
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300">
<Calendar className="w-3 h-3 mr-1" />
Est. {designer.founded_year}
</Badge>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end mt-4 pt-3 border-t border-border/50">
<Button
size="sm"
className="bg-gradient-to-r from-primary to-secondary hover:shadow-lg hover:shadow-primary/20 hover:scale-105 transition-all duration-300 flex-shrink-0"
>
<Palette className="w-3.5 h-3.5 mr-1.5" />
<span className="hidden sm:inline">View Details</span>
<span className="sm:hidden">View</span>
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,187 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, ArrowLeft, RefreshCw, Shield } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { handleError } from '@/lib/errorHandler';
interface AdminErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
section?: string; // e.g., "Moderation", "Users", "Settings"
}
type ErrorWithId = Error & { errorId: string };
interface AdminErrorBoundaryState {
hasError: boolean;
error: ErrorWithId | null;
errorInfo: ErrorInfo | null;
}
/**
* Admin Error Boundary Component (P0 #5)
*
* Specialized error boundary for admin sections.
* Prevents admin panel errors from affecting the entire app.
*
* Usage:
* ```tsx
* <AdminErrorBoundary section="User Management">
* <UserManagement />
* </AdminErrorBoundary>
* ```
*/
export class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
constructor(props: AdminErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<AdminErrorBoundaryState> {
return {
hasError: true,
error: error as ErrorWithId,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to database and get error ID for user reference
const errorId = handleError(error, {
action: `Admin panel error in ${this.props.section || 'unknown section'}`,
metadata: {
section: this.props.section,
componentStack: errorInfo.componentStack,
severity: 'high',
},
});
this.setState({ errorInfo, error: { ...error, errorId } as ErrorWithId });
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
handleBackToDashboard = () => {
window.location.href = '/admin';
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-[500px] flex items-center justify-center p-6">
<Card className="max-w-3xl w-full border-destructive/50 bg-destructive/5">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-destructive/10">
<Shield className="w-6 h-6 text-destructive" />
</div>
<div>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
Admin Panel Error
</CardTitle>
<CardDescription className="mt-1">
{this.props.section
? `An error occurred in ${this.props.section}`
: 'An error occurred in the admin panel'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p className="text-sm">
{this.state.error?.message || 'An unexpected error occurred in the admin panel'}
</p>
{(this.state.error as any)?.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {((this.state.error as any).errorId as string).slice(0, 8)}
</p>
)}
<p className="text-xs text-muted-foreground">
This error has been logged. If the problem persists, please contact support.
</p>
</div>
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="default"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
<Button
variant="outline"
size="sm"
onClick={this.handleBackToDashboard}
className="gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify({
section: this.props.section,
error: this.state.error?.message,
stack: this.state.error?.stack,
timestamp: new Date().toISOString(),
url: window.location.href,
}, null, 2)
);
}}
>
Copy Error Report
</Button>
</div>
{import.meta.env.DEV && this.state.errorInfo && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground font-medium">
Show Stack Trace (Development Only)
</summary>
<div className="mt-2 space-y-2">
<pre className="overflow-auto p-3 bg-muted rounded text-xs max-h-[200px]">
{this.state.error?.stack}
</pre>
<pre className="overflow-auto p-3 bg-muted rounded text-xs max-h-[200px]">
{this.state.errorInfo.componentStack}
</pre>
</div>
</details>
)}
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,186 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { handleError } from '@/lib/errorHandler';
interface EntityErrorBoundaryProps {
children: ReactNode;
entityType: 'park' | 'ride' | 'manufacturer' | 'designer' | 'operator' | 'owner';
entitySlug?: string;
fallback?: ReactNode;
}
interface EntityErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
type ErrorWithId = Error & { errorId: string };
export class EntityErrorBoundary extends Component<EntityErrorBoundaryProps, EntityErrorBoundaryState> {
constructor(props: EntityErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<EntityErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to database and get error ID for user reference
const errorId = handleError(error, {
action: `${this.props.entityType} page error`,
metadata: {
entityType: this.props.entityType,
entitySlug: this.props.entitySlug,
componentStack: errorInfo.componentStack,
},
});
this.setState({ errorInfo, error: { ...error, errorId } as ErrorWithId });
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
handleBackToList = () => {
const listPages: Record<string, string> = {
park: '/parks',
ride: '/rides',
manufacturer: '/manufacturers',
designer: '/designers',
operator: '/operators',
owner: '/owners',
};
window.location.href = listPages[this.props.entityType] || '/';
};
handleGoHome = () => {
window.location.href = '/';
};
getEntityLabel() {
const labels: Record<string, string> = {
park: 'Park',
ride: 'Ride',
manufacturer: 'Manufacturer',
designer: 'Designer',
operator: 'Operator',
owner: 'Property Owner',
};
return labels[this.props.entityType] || 'Entity';
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
const entityLabel = this.getEntityLabel();
return (
<div className="container mx-auto px-4 py-12">
<Card className="max-w-2xl mx-auto border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
Failed to Load {entityLabel}
</CardTitle>
<CardDescription>
{this.props.entitySlug
? `Unable to display ${entityLabel.toLowerCase()}: ${this.props.entitySlug}`
: `Unable to display this ${entityLabel.toLowerCase()}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p className="text-sm">
{this.state.error?.message || `An unexpected error occurred while loading this ${entityLabel.toLowerCase()}`}
</p>
{(this.state.error as ErrorWithId)?.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {(this.state.error as ErrorWithId).errorId.slice(0, 8)}
</p>
)}
<p className="text-xs text-muted-foreground">
This might be due to:
</p>
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-1">
<li>The {entityLabel.toLowerCase()} no longer exists</li>
<li>Temporary data loading issues</li>
<li>Network connectivity problems</li>
</ul>
</div>
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="default"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Try Again
</Button>
<Button
variant="outline"
size="sm"
onClick={this.handleBackToList}
className="gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back to {entityLabel}s
</Button>
<Button
variant="ghost"
size="sm"
onClick={this.handleGoHome}
className="gap-2"
>
<Home className="w-4 h-4" />
Home
</Button>
</div>
{import.meta.env.DEV && this.state.errorInfo && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show Debug Info (Development Only)
</summary>
<pre className="mt-2 overflow-auto p-3 bg-muted rounded text-xs max-h-[300px]">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,157 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, Home, RefreshCw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { handleError } from '@/lib/errorHandler';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
context?: string; // e.g., "PhotoUpload", "ParkDetail"
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
type ErrorWithId = Error & { errorId: string };
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to database and get error ID for user reference
const errorId = handleError(error, {
action: `Component error in ${this.props.context || 'unknown context'}`,
metadata: {
context: this.props.context,
componentStack: errorInfo.componentStack,
},
});
this.setState({ errorInfo, error: { ...error, errorId } as ErrorWithId });
this.props.onError?.(error, errorInfo);
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
handleGoHome = () => {
window.location.href = '/';
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-[400px] flex items-center justify-center p-4">
<Card className="max-w-2xl w-full border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
Something Went Wrong
</CardTitle>
<CardDescription>
{this.props.context
? `An error occurred in ${this.props.context}`
: 'An unexpected error occurred'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<p className="text-sm mt-2">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
{(this.state.error as ErrorWithId)?.errorId && (
<p className="text-xs mt-2 font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {(this.state.error as ErrorWithId).errorId.slice(0, 8)}
</p>
)}
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="default"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Try Again
</Button>
<Button
variant="outline"
size="sm"
onClick={this.handleGoHome}
className="gap-2"
>
<Home className="w-4 h-4" />
Go Home
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify({
context: this.props.context,
error: this.state.error?.message,
stack: this.state.error?.stack,
timestamp: new Date().toISOString(),
}, null, 2)
);
}}
>
Copy Error Details
</Button>
</div>
{import.meta.env.DEV && this.state.errorInfo && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show Component Stack (Development Only)
</summary>
<pre className="mt-2 overflow-auto p-3 bg-muted rounded text-xs max-h-[300px]">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,167 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { handleError } from '@/lib/errorHandler';
interface ModerationErrorBoundaryProps {
children: ReactNode;
submissionId?: string;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ModerationErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
type ErrorWithId = Error & { errorId: string };
/**
* Error Boundary for Moderation Queue Components
*
* Prevents individual queue item render errors from crashing the entire queue.
* Shows user-friendly error UI with retry functionality.
*
* Usage:
* ```tsx
* <ModerationErrorBoundary submissionId={item.id}>
* <QueueItem item={item} />
* </ModerationErrorBoundary>
* ```
*/
export class ModerationErrorBoundary extends Component<
ModerationErrorBoundaryProps,
ModerationErrorBoundaryState
> {
constructor(props: ModerationErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ModerationErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to database and get error ID for user reference
const errorId = handleError(error, {
action: 'Moderation queue item render error',
metadata: {
submissionId: this.props.submissionId,
componentStack: errorInfo.componentStack,
},
});
// Update state with error info
this.setState({
error: { ...error, errorId } as ErrorWithId,
errorInfo,
});
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
// Custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<Card className="border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/10">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-300">
<AlertCircle className="w-5 h-5" />
Queue Item Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to render submission</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p className="text-sm">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
{(this.state.error as ErrorWithId)?.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {(this.state.error as ErrorWithId).errorId.slice(0, 8)}
</p>
)}
{this.props.submissionId && (
<p className="text-xs text-muted-foreground font-mono">
Submission ID: {this.props.submissionId}
</p>
)}
</div>
</AlertDescription>
</Alert>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify({
error: this.state.error?.message,
stack: this.state.error?.stack,
submissionId: this.props.submissionId,
}, null, 2)
);
}}
>
Copy Error Details
</Button>
</div>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show Component Stack
</summary>
<pre className="mt-2 overflow-auto p-2 bg-muted rounded text-xs">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { WifiOff, RefreshCw, X, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface NetworkErrorBannerProps {
isOffline: boolean;
pendingCount?: number;
onRetryNow?: () => Promise<void>;
onViewQueue?: () => void;
estimatedRetryTime?: Date;
}
export function NetworkErrorBanner({
isOffline,
pendingCount = 0,
onRetryNow,
onViewQueue,
estimatedRetryTime,
}: NetworkErrorBannerProps) {
const [isVisible, setIsVisible] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
useEffect(() => {
setIsVisible(isOffline || pendingCount > 0);
}, [isOffline, pendingCount]);
useEffect(() => {
if (!estimatedRetryTime) {
setCountdown(null);
return;
}
const interval = setInterval(() => {
const now = Date.now();
const remaining = Math.max(0, estimatedRetryTime.getTime() - now);
setCountdown(Math.ceil(remaining / 1000));
if (remaining <= 0) {
clearInterval(interval);
setCountdown(null);
}
}, 1000);
return () => clearInterval(interval);
}, [estimatedRetryTime]);
const handleRetryNow = async () => {
if (!onRetryNow) return;
setIsRetrying(true);
try {
await onRetryNow();
} finally {
setIsRetrying(false);
}
};
if (!isVisible) return null;
return (
<div
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-transform duration-300",
isVisible ? "translate-y-0" : "-translate-y-full"
)}
>
<div className="bg-destructive/90 backdrop-blur-sm text-destructive-foreground shadow-lg">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<WifiOff className="h-5 w-5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">
{isOffline ? 'You are offline' : 'Network Issue Detected'}
</p>
<p className="text-xs opacity-90 truncate">
{pendingCount > 0 ? (
<>
{pendingCount} submission{pendingCount !== 1 ? 's' : ''} pending
{countdown !== null && countdown > 0 && (
<span className="ml-2">
· Retrying in {countdown}s
</span>
)}
</>
) : (
'Changes will sync when connection is restored'
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{pendingCount > 0 && onViewQueue && (
<Button
size="sm"
variant="secondary"
onClick={onViewQueue}
className="h-8 text-xs bg-background/20 hover:bg-background/30"
>
<Eye className="h-3.5 w-3.5 mr-1.5" />
View Queue ({pendingCount})
</Button>
)}
{onRetryNow && (
<Button
size="sm"
variant="secondary"
onClick={handleRetryNow}
disabled={isRetrying}
className="h-8 text-xs bg-background/20 hover:bg-background/30"
>
<RefreshCw className={cn(
"h-3.5 w-3.5 mr-1.5",
isRetrying && "animate-spin"
)} />
{isRetrying ? 'Retrying...' : 'Retry Now'}
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => setIsVisible(false)}
className="h-8 w-8 p-0 hover:bg-background/20"
>
<X className="h-4 w-4" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { handleError } from '@/lib/errorHandler';
interface RouteErrorBoundaryProps {
children: ReactNode;
}
interface RouteErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
type ErrorWithId = Error & { errorId: string };
export class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
constructor(props: RouteErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): Partial<RouteErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Detect chunk load failures (deployment cache issue)
const isChunkLoadError =
error.message.includes('Failed to fetch dynamically imported module') ||
error.message.includes('Loading chunk') ||
error.message.includes('ChunkLoadError');
if (isChunkLoadError) {
// Check if we've already tried reloading
const hasReloaded = sessionStorage.getItem('chunk-load-reload');
if (!hasReloaded) {
// Mark as reloaded and reload once
sessionStorage.setItem('chunk-load-reload', 'true');
window.location.reload();
return; // Don't log error yet
} else {
// Second failure - clear flag and show error
sessionStorage.removeItem('chunk-load-reload');
}
}
// Log to database and get error ID for user reference
const errorId = handleError(error, {
action: 'Route-level component crash',
metadata: {
componentStack: errorInfo.componentStack,
url: window.location.href,
severity: isChunkLoadError ? 'medium' : 'critical',
isChunkLoadError,
},
});
this.setState({ error: { ...error, errorId } as ErrorWithId });
}
handleReload = () => {
window.location.reload();
};
handleClearCacheAndReload = async () => {
try {
// Clear all caches
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
// Unregister service workers
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(reg => reg.unregister()));
}
// Clear session storage chunk reload flag
sessionStorage.removeItem('chunk-load-reload');
// Force reload bypassing cache
window.location.reload();
} catch (error) {
// Fallback to regular reload if cache clearing fails
console.error('Failed to clear cache:', error);
window.location.reload();
}
};
handleGoHome = () => {
window.location.href = '/';
};
render() {
if (this.state.hasError) {
const isChunkError =
this.state.error?.message.includes('Failed to fetch dynamically imported module') ||
this.state.error?.message.includes('Loading chunk') ||
this.state.error?.message.includes('ChunkLoadError');
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<Card className="max-w-lg w-full shadow-lg">
<CardHeader className="text-center pb-4">
<div className="mx-auto w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-destructive" />
</div>
<CardTitle className="text-2xl">
{isChunkError ? 'App Update Required' : 'Something Went Wrong'}
</CardTitle>
<CardDescription className="mt-2 space-y-2">
{isChunkError ? (
<>
<p>The app has been updated with new features and improvements.</p>
<p className="text-sm font-medium">
To continue, please clear your browser cache and reload:
</p>
<ul className="text-sm list-disc list-inside space-y-1 ml-2">
<li>Click "Clear Cache & Reload" below, or</li>
<li>Press <kbd className="px-1.5 py-0.5 text-xs font-semibold bg-muted rounded">Ctrl+Shift+R</kbd> (Windows/Linux) or <kbd className="px-1.5 py-0.5 text-xs font-semibold bg-muted rounded">+Shift+R</kbd> (Mac)</li>
</ul>
</>
) : (
"We encountered an unexpected error. This has been logged and we'll look into it."
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{this.state.error && (
<div className="p-3 bg-muted rounded-lg space-y-2">
{import.meta.env.DEV && (
<p className="text-xs font-mono text-muted-foreground">
{this.state.error.message}
</p>
)}
{(this.state.error as ErrorWithId)?.errorId && (
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
Reference ID: {(this.state.error as ErrorWithId).errorId.slice(0, 8)}
</p>
)}
</div>
)}
<div className="flex flex-col gap-2">
{isChunkError && (
<Button
variant="default"
onClick={this.handleClearCacheAndReload}
className="w-full gap-2"
>
<RefreshCw className="w-4 h-4" />
Clear Cache & Reload
</Button>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant={isChunkError ? "outline" : "default"}
onClick={this.handleReload}
className="flex-1 gap-2"
>
<RefreshCw className="w-4 h-4" />
Reload Page
</Button>
<Button
variant="outline"
onClick={this.handleGoHome}
className="flex-1 gap-2"
>
<Home className="w-4 h-4" />
Go Home
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
If this problem persists, please contact support
</p>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ModerationErrorBoundary } from './ModerationErrorBoundary';
interface SubmissionErrorBoundaryProps {
children: ReactNode;
submissionId?: string;
}
/**
* Lightweight Error Boundary for Submission-Related Components
*
* Wraps ModerationErrorBoundary with a submission-specific fallback UI.
* Use this for any component that displays submission data.
*
* Usage:
* ```tsx
* <SubmissionErrorBoundary submissionId={id}>
* <SubmissionDetails />
* </SubmissionErrorBoundary>
* ```
*/
export function SubmissionErrorBoundary({
children,
submissionId
}: SubmissionErrorBoundaryProps) {
return (
<ModerationErrorBoundary
submissionId={submissionId}
fallback={
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load submission data. Please try refreshing the page.
</AlertDescription>
</Alert>
}
>
{children}
</ModerationErrorBoundary>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Error Boundary Components (P0 #5 - Critical)
*
* Prevents component errors from crashing the entire application.
* Provides user-friendly error UIs with recovery options.
*/
export { ErrorBoundary } from './ErrorBoundary';
export { AdminErrorBoundary } from './AdminErrorBoundary';
export { EntityErrorBoundary } from './EntityErrorBoundary';
export { RouteErrorBoundary } from './RouteErrorBoundary';
export { ModerationErrorBoundary } from './ModerationErrorBoundary';
export { SubmissionErrorBoundary } from './SubmissionErrorBoundary';

View File

@@ -0,0 +1,48 @@
import { Label } from '@/components/ui/label';
import { MonthYearPicker } from '@/components/ui/month-year-picker';
interface FilterDateRangePickerProps {
label: string;
fromDate: Date | null;
toDate: Date | null;
onFromChange: (date: Date | undefined) => void;
onToChange: (date: Date | undefined) => void;
fromYear?: number;
toYear?: number;
fromPlaceholder?: string;
toPlaceholder?: string;
}
export function FilterDateRangePicker({
label,
fromDate,
toDate,
onFromChange,
onToChange,
fromYear = 1900,
toYear = new Date().getFullYear(),
fromPlaceholder = 'From',
toPlaceholder = 'To'
}: FilterDateRangePickerProps) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex gap-2">
<MonthYearPicker
date={fromDate || undefined}
onSelect={onFromChange}
placeholder={fromPlaceholder}
fromYear={fromYear}
toYear={toYear}
/>
<MonthYearPicker
date={toDate || undefined}
onSelect={onToChange}
placeholder={toPlaceholder}
fromYear={fromYear}
toYear={toYear}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Label } from '@/components/ui/label';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
interface FilterMultiSelectComboboxProps {
label: string;
options: MultiSelectOption[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
emptyText?: string;
maxDisplay?: number;
}
export function FilterMultiSelectCombobox({
label,
options,
value,
onChange,
placeholder = 'Select...',
emptyText = 'No options found',
maxDisplay = 2
}: FilterMultiSelectComboboxProps) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<MultiSelectCombobox
options={options}
value={value}
onValueChange={onChange}
placeholder={placeholder}
emptyText={emptyText}
maxDisplay={maxDisplay}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
interface FilterRangeSliderProps {
label: string;
value: [number, number];
onChange: (value: [number, number]) => void;
min: number;
max: number;
step?: number;
unit?: string;
formatValue?: (value: number) => string;
}
export function FilterRangeSlider({
label,
value,
onChange,
min,
max,
step = 1,
unit = '',
formatValue
}: FilterRangeSliderProps) {
const format = formatValue || ((v: number) => `${v}${unit}`);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>{label}</Label>
<Badge variant="outline">
{format(value[0])} - {format(value[1])}
</Badge>
</div>
<div className="px-2">
<Slider
value={value}
onValueChange={onChange}
min={min}
max={max}
step={step}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{format(min)}</span>
<span>{format(max)}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { Separator } from '@/components/ui/separator';
interface FilterSectionProps {
title?: string;
children: ReactNode;
showSeparator?: boolean;
}
export function FilterSection({ title, children, showSeparator = false }: FilterSectionProps) {
return (
<div className="space-y-4">
{title && <h4 className="text-sm font-semibold text-muted-foreground">{title}</h4>}
{children}
{showSeparator && <Separator className="my-6" />}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useState, useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { CalendarIcon, X } from 'lucide-react';
import { toDateOnly, parseDateForDisplay, getCurrentDateLocal, formatDateDisplay } from '@/lib/dateUtils';
import { cn } from '@/lib/utils';
import type { DateRange } from 'react-day-picker';
interface TimeZoneIndependentDateRangePickerProps {
label?: string;
fromDate?: string | null;
toDate?: string | null;
onFromChange: (date: string | null) => void;
onToChange: (date: string | null) => void;
fromPlaceholder?: string;
toPlaceholder?: string;
fromYear?: number;
toYear?: number;
presets?: Array<{
label: string;
from?: string;
to?: string;
}>;
}
export function TimeZoneIndependentDateRangePicker({
label = 'Date Range',
fromDate,
toDate,
onFromChange,
onToChange,
fromPlaceholder = 'From date',
toPlaceholder = 'To date',
fromYear = 1800,
toYear = new Date().getFullYear(),
presets,
}: TimeZoneIndependentDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
// Default presets for ride/park filtering
const defaultPresets = useMemo(() => {
const currentYear = new Date().getFullYear();
return [
{ label: 'Last Year', from: `${currentYear - 1}-01-01`, to: `${currentYear - 1}-12-31` },
{ label: 'Last 5 Years', from: `${currentYear - 5}-01-01`, to: getCurrentDateLocal() },
{ label: 'Last 10 Years', from: `${currentYear - 10}-01-01`, to: getCurrentDateLocal() },
{ label: '1990s', from: '1990-01-01', to: '1999-12-31' },
{ label: '2000s', from: '2000-01-01', to: '2009-12-31' },
{ label: '2010s', from: '2010-01-01', to: '2019-12-31' },
{ label: '2020s', from: '2020-01-01', to: '2029-12-31' },
];
}, []);
const activePresets = presets || defaultPresets;
// Convert YYYY-MM-DD strings to Date objects for calendar display
const dateRange: DateRange | undefined = useMemo(() => {
if (!fromDate && !toDate) return undefined;
return {
from: fromDate ? parseDateForDisplay(fromDate) : undefined,
to: toDate ? parseDateForDisplay(toDate) : undefined,
};
}, [fromDate, toDate]);
// Handle calendar selection
const handleSelect = (range: DateRange | undefined) => {
if (range?.from) {
const fromString = toDateOnly(range.from);
onFromChange(fromString);
} else {
onFromChange(null);
}
if (range?.to) {
const toString = toDateOnly(range.to);
onToChange(toString);
} else if (!range?.from) {
// If from is cleared, clear to as well
onToChange(null);
}
};
// Handle preset selection
const handlePresetSelect = (preset: { from?: string; to?: string }) => {
onFromChange(preset.from || null);
onToChange(preset.to || null);
setIsOpen(false);
};
// Handle clear
const handleClear = () => {
onFromChange(null);
onToChange(null);
};
// Format range for display
const formatRange = () => {
if (!fromDate && !toDate) return null;
if (fromDate && toDate) {
return `${formatDateDisplay(fromDate, 'day')} - ${formatDateDisplay(toDate, 'day')}`;
} else if (fromDate) {
return `From ${formatDateDisplay(fromDate, 'day')}`;
} else if (toDate) {
return `Until ${formatDateDisplay(toDate, 'day')}`;
}
return null;
};
const displayText = formatRange();
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<div className="flex items-center gap-2">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!displayText && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{displayText || `${fromPlaceholder} - ${toPlaceholder}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex flex-col sm:flex-row">
{/* Presets sidebar */}
<div className="border-b sm:border-b-0 sm:border-r border-border p-3 space-y-1">
<div className="text-sm font-semibold mb-2 text-muted-foreground">Presets</div>
{activePresets.map((preset) => (
<Button
key={preset.label}
variant="ghost"
size="sm"
className="w-full justify-start font-normal"
onClick={() => handlePresetSelect(preset)}
>
{preset.label}
</Button>
))}
</div>
{/* Calendar */}
<div className="p-3">
<Calendar
mode="range"
selected={dateRange}
onSelect={handleSelect}
numberOfMonths={2}
defaultMonth={dateRange?.from || new Date()}
fromYear={fromYear}
toYear={toYear}
className="pointer-events-auto"
/>
</div>
</div>
</PopoverContent>
</Popover>
{displayText && (
<Button
variant="ghost"
size="icon"
onClick={handleClear}
className="shrink-0"
title="Clear date range"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{displayText && (
<Badge variant="secondary" className="text-xs">
{fromDate && toDate
? `${fromDate} to ${toDate}`
: fromDate
? `From ${fromDate}`
: toDate
? `Until ${toDate}`
: ''}
</Badge>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EntityTimelineManager } from '@/components/timeline/EntityTimelineManager';
import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory';
import { FormerNamesSection } from './FormerNamesSection';
import { RideNameHistory } from '@/types/database';
import type { EntityType } from '@/types/timeline';
interface EntityHistoryTabsProps {
entityType: EntityType;
entityId: string;
entityName: string;
formerNames?: RideNameHistory[];
currentName?: string;
}
const getHistoryLabel = (entityType: string) => {
switch (entityType) {
case 'park':
return 'Park History';
case 'ride':
return 'Ride History';
case 'company':
return 'Company History';
default:
return 'History';
}
};
const getHistoryValue = (entityType: string) => {
switch (entityType) {
case 'park':
return 'park-history';
case 'ride':
return 'ride-history';
case 'company':
return 'company-history';
default:
return 'entity-history';
}
};
export function EntityHistoryTabs({
entityType,
entityId,
entityName,
formerNames,
currentName,
}: EntityHistoryTabsProps) {
const historyValue = getHistoryValue(entityType);
const historyLabel = getHistoryLabel(entityType);
return (
<Tabs defaultValue={historyValue} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value={historyValue}>{historyLabel}</TabsTrigger>
<TabsTrigger value="version-history">Version History</TabsTrigger>
</TabsList>
<TabsContent value={historyValue} className="mt-6 space-y-6">
{formerNames && formerNames.length > 0 && currentName && (
<FormerNamesSection
currentName={currentName}
formerNames={formerNames}
entityType={entityType}
/>
)}
{/* Dynamic Timeline Manager with Edit/Delete */}
<EntityTimelineManager
entityType={entityType}
entityId={entityId}
entityName={entityName}
/>
</TabsContent>
<TabsContent value="version-history" className="mt-6">
<EntityVersionHistory
entityType={entityType}
entityId={entityId}
entityName={entityName}
/>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,137 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Calendar, MapPin, Building2, Tag, Milestone, ArrowRight } from "lucide-react";
import { format } from "date-fns";
export type HistoryEventType = 'name_change' | 'status_change' | 'ownership_change' | 'relocation' | 'milestone';
export interface HistoryEvent {
date: string;
title: string;
description?: string;
type: HistoryEventType;
from?: string;
to?: string;
}
interface EntityHistoryTimelineProps {
events: HistoryEvent[];
entityName: string;
}
const eventTypeConfig: Record<HistoryEventType, { icon: typeof Tag; color: string; label: string }> = {
name_change: { icon: Tag, color: 'text-blue-500', label: 'Name Change' },
status_change: { icon: Calendar, color: 'text-amber-500', label: 'Status Change' },
ownership_change: { icon: Building2, color: 'text-purple-500', label: 'Ownership Change' },
relocation: { icon: MapPin, color: 'text-green-500', label: 'Relocation' },
milestone: { icon: Milestone, color: 'text-pink-500', label: 'Milestone' },
};
// Fallback config for unknown event types
const defaultEventConfig = { icon: Tag, color: 'text-gray-500', label: 'Event' };
export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimelineProps) {
if (events.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>No Historical Events</CardTitle>
<CardDescription>
No historical events recorded for {entityName}
</CardDescription>
</CardHeader>
</Card>
);
}
// Sort events by date (most recent first)
const sortedEvents = [...events].sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
return (
<div className="space-y-4">
<div className="relative space-y-4">
{/* Timeline line */}
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
{sortedEvents.map((event, index) => {
// Safety check: verify event.type exists in eventTypeConfig, use fallback if not
const config = event.type && eventTypeConfig[event.type]
? eventTypeConfig[event.type]
: defaultEventConfig;
const Icon = config.icon;
return (
<div key={index} className="relative flex gap-4">
{/* Timeline dot */}
<div className="relative flex-shrink-0">
<div className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-background bg-card ${config.color}`}>
<Icon className="h-5 w-5" />
</div>
</div>
{/* Event content */}
<Card className="flex-1">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-lg">{event.title}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Calendar className="h-3 w-3" />
{formatEventDate(event.date)}
<span className="text-xs"></span>
<span className={config.color}>{config.label}</span>
</CardDescription>
</div>
</div>
</CardHeader>
{(event.description || (event.from && event.to)) && (
<CardContent className="pt-0">
{event.from && event.to && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span className="font-medium">{event.from}</span>
<ArrowRight className="h-4 w-4" />
<span className="font-medium">{event.to}</span>
</div>
)}
{event.description && (
<p className="text-sm text-muted-foreground">{event.description}</p>
)}
</CardContent>
)}
</Card>
</div>
);
})}
</div>
</div>
);
}
function formatEventDate(dateString: string): string {
// Safety check: validate dateString exists and is a string
if (!dateString || typeof dateString !== 'string') {
return 'Unknown date';
}
try {
// Handle year-only dates
if (/^\d{4}$/.test(dateString)) {
return dateString;
}
// Validate date string before creating Date object
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return dateString;
}
return format(date, 'MMMM d, yyyy');
} catch {
return dateString;
}
}

View File

@@ -0,0 +1,100 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tag, Calendar } from "lucide-react";
interface FormerName {
former_name?: string;
name?: string;
from_year?: number;
to_year?: number;
reason?: string;
date_changed?: string;
}
interface FormerNamesSectionProps {
currentName: string;
formerNames: FormerName[];
entityType: 'ride' | 'park' | 'company' | 'ride_model';
}
export function FormerNamesSection({ currentName, formerNames, entityType }: FormerNamesSectionProps) {
if (!formerNames || formerNames.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Former Names
</CardTitle>
<CardDescription>
This {entityType} has not had any previous names
</CardDescription>
</CardHeader>
</Card>
);
}
// Sort by date (most recent first)
const sortedNames = [...formerNames].sort((a, b) => {
const yearA = a.to_year || (a.date_changed ? new Date(a.date_changed).getFullYear() : 0);
const yearB = b.to_year || (b.date_changed ? new Date(b.date_changed).getFullYear() : 0);
return yearB - yearA;
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Former Names
</CardTitle>
<CardDescription>
Historical names for this {entityType}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Current name */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20">
<div className="flex-shrink-0 mt-1">
<div className="h-3 w-3 rounded-full bg-primary" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-foreground">{currentName}</h4>
<p className="text-sm text-muted-foreground">Current Name</p>
</div>
</div>
{/* Former names */}
{sortedNames.map((name, index) => {
const displayName = name.former_name || name.name;
const yearRange = name.from_year && name.to_year
? `${name.from_year} - ${name.to_year}`
: name.date_changed
? `Until ${new Date(name.date_changed).getFullYear()}`
: null;
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg border">
<div className="flex-shrink-0 mt-1">
<div className="h-3 w-3 rounded-full bg-muted-foreground/30" />
</div>
<div className="flex-1 space-y-1">
<h4 className="font-medium text-foreground">{displayName}</h4>
{yearRange && (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
{yearRange}
</p>
)}
{name.reason && (
<p className="text-sm text-muted-foreground italic">{name.reason}</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ParkCard } from '@/components/parks/ParkCard';
import { RideCard } from '@/components/rides/RideCard';
import { RecentChangeCard } from './RecentChangeCard';
import { Badge } from '@/components/ui/badge';
import { Clock } from 'lucide-react';
import { useHomepageTrendingParks, useHomepageTrendingRides } from '@/hooks/homepage/useHomepageTrending';
import { useHomepageRecentParks, useHomepageRecentRides } from '@/hooks/homepage/useHomepageRecent';
import { useHomepageRecentChanges } from '@/hooks/homepage/useHomepageRecentChanges';
import { useHomepageRecentlyOpenedParks, useHomepageRecentlyOpenedRides } from '@/hooks/homepage/useHomepageOpened';
import { useHomepageHighestRatedParks, useHomepageHighestRatedRides } from '@/hooks/homepage/useHomepageRated';
import { useHomepageOpeningSoonParks, useHomepageOpeningSoonRides } from '@/hooks/homepage/useHomepageOpeningSoon';
import { useHomepageClosingSoonParks, useHomepageClosingSoonRides } from '@/hooks/homepage/useHomepageClosing';
import { useHomepageRecentlyClosedParks, useHomepageRecentlyClosedRides } from '@/hooks/homepage/useHomepageClosed';
export function ContentTabs() {
const [activeTab, setActiveTab] = useState('trending-parks');
// Lazy load data - only fetch when tab is active
const trendingParks = useHomepageTrendingParks(activeTab === 'trending-parks');
const trendingRides = useHomepageTrendingRides(activeTab === 'trending-rides');
const recentParks = useHomepageRecentParks(activeTab === 'recent-parks');
const recentRides = useHomepageRecentRides(activeTab === 'recent-rides');
const recentChanges = useHomepageRecentChanges(activeTab === 'recent-changes');
const recentlyOpenedParks = useHomepageRecentlyOpenedParks(activeTab === 'recently-opened');
const recentlyOpenedRides = useHomepageRecentlyOpenedRides(activeTab === 'recently-opened');
const highestRatedParks = useHomepageHighestRatedParks(activeTab === 'highest-rated-parks');
const highestRatedRides = useHomepageHighestRatedRides(activeTab === 'highest-rated-rides');
const openingSoonParks = useHomepageOpeningSoonParks(activeTab === 'opening-soon');
const openingSoonRides = useHomepageOpeningSoonRides(activeTab === 'opening-soon');
const closingSoonParks = useHomepageClosingSoonParks(activeTab === 'closing-soon');
const closingSoonRides = useHomepageClosingSoonRides(activeTab === 'closing-soon');
const recentlyClosedParks = useHomepageRecentlyClosedParks(activeTab === 'recently-closed');
const recentlyClosedRides = useHomepageRecentlyClosedRides(activeTab === 'recently-closed');
// Combine parks and rides for mixed tabs
const recentlyOpened = [
...(recentlyOpenedParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(recentlyOpenedRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(b.opening_date || 0).getTime() - new Date(a.opening_date || 0).getTime()).slice(0, 24);
const openingSoon = [
...(openingSoonParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(openingSoonRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(a.opening_date || 0).getTime() - new Date(b.opening_date || 0).getTime()).slice(0, 24);
const closingSoon = [
...(closingSoonParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(closingSoonRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(a.closing_date || 0).getTime() - new Date(b.closing_date || 0).getTime()).slice(0, 24);
const recentlyClosed = [
...(recentlyClosedParks.data || []).map(p => ({ ...p, entityType: 'park' as const })),
...(recentlyClosedRides.data || []).map(r => ({ ...r, entityType: 'ride' as const }))
].sort((a, b) => new Date(b.closing_date || 0).getTime() - new Date(a.closing_date || 0).getTime()).slice(0, 24);
const isLoadingInitial = activeTab === 'trending-parks' && trendingParks.isLoading;
if (isLoadingInitial) {
return (
<section className="py-12">
<div className="container mx-auto px-4">
<div className="space-y-8">
<div className="text-center space-y-3">
<div className="h-8 bg-gradient-to-r from-primary/20 via-secondary/20 to-accent/20 rounded-lg w-64 mx-auto animate-pulse" />
<div className="h-4 bg-muted/50 rounded w-96 mx-auto animate-pulse" style={{ animationDelay: '150ms' }} />
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
</div>
<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 gap-4">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="h-72 bg-gradient-to-br from-card via-card to-card/80 rounded-lg border border-border/50 animate-pulse relative overflow-hidden"
style={{ animationDelay: `${i * 50}ms` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-primary/10 to-transparent animate-shimmer" />
</div>
))}
</div>
</div>
</div>
</section>
);
}
return (
<section className="py-8">
<div className="container mx-auto px-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="text-center mb-8">
<TabsList className="flex flex-wrap justify-center gap-2 p-3 bg-muted/30 rounded-lg max-w-5xl mx-auto">
<TabsTrigger value="trending-parks" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Trending Parks
</TabsTrigger>
<TabsTrigger value="trending-rides" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Trending Rides
</TabsTrigger>
<TabsTrigger value="recent-parks" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
New Parks
</TabsTrigger>
<TabsTrigger value="recent-rides" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
New Rides
</TabsTrigger>
<TabsTrigger value="recent-changes" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Recent Changes
</TabsTrigger>
<TabsTrigger value="highest-rated-parks" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Top Parks
</TabsTrigger>
<TabsTrigger value="highest-rated-rides" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Top Rides
</TabsTrigger>
<TabsTrigger value="opening-soon" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Opening Soon
</TabsTrigger>
<TabsTrigger value="recently-opened" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Recently Opened
</TabsTrigger>
<TabsTrigger value="closing-soon" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Closing Soon
</TabsTrigger>
<TabsTrigger value="recently-closed" className="px-4 py-2.5 text-sm font-medium rounded-full data-[state=active]:bg-primary data-[state=active]:text-primary-foreground hover:bg-muted/50 transition-colors text-center md:w-[calc(25%-0.375rem)]">
Recently Closed
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="trending-parks" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Trending Parks</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Most viewed parks in the last 30 days</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{trendingParks.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<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">
{trendingParks.data?.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
)}
</TabsContent>
<TabsContent value="trending-rides" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Trending Rides</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Most viewed rides in the last 30 days</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{trendingRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<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">
{trendingRides.data?.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-parks" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Recently Added Parks</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest parks added to our database</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentParks.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<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">
{recentParks.data?.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-rides" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Recently Added Rides</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest attractions added to our database</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<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">
{recentRides.data?.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
)}
</TabsContent>
<TabsContent value="recent-changes" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Recent Changes</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Latest updates across all entities</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentChanges.isLoading ? (
<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 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-64 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : recentChanges.data && recentChanges.data.length > 0 ? (
<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 gap-4">
{recentChanges.data.map((change) => (
<RecentChangeCard
key={`${change.type}-${change.id}-${change.changedAt}`}
entityType={change.type}
entityId={change.id}
entityName={change.name}
entitySlug={change.slug}
parkSlug={change.parkSlug}
imageUrl={change.imageUrl}
changeType={change.changeType}
changedAt={change.changedAt}
changedByUsername={change.changedBy?.username}
changedByAvatar={change.changedBy?.avatarUrl}
changeReason={change.changeReason}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-2xl animate-pulse" />
<div className="relative w-20 h-20 rounded-full bg-gradient-to-br from-card via-card to-card/80 flex items-center justify-center border-2 border-primary/20 shadow-xl">
<Clock className="w-10 h-10 text-muted-foreground" />
</div>
</div>
<h3 className="text-xl font-semibold mb-2 text-foreground">No Recent Changes</h3>
<p className="text-muted-foreground text-center max-w-md text-sm">
There are no recent entity changes to display yet. Check back soon for the latest updates to parks, rides, and companies!
</p>
<div className="mt-6 w-32 h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent rounded-full" />
</div>
)}
</TabsContent>
<TabsContent value="recently-opened" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Recently Opened</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides that opened in the last year</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentlyOpenedParks.isLoading || recentlyOpenedRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : (
<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">
{recentlyOpened.map((entity) => (
entity.entityType === 'park' ? (
<div key={entity.id} className="relative">
<ParkCard park={entity} />
{entity.opening_date && (
<Badge className="absolute top-2 right-2 bg-green-500/90 text-white backdrop-blur-sm">
{new Date(entity.opening_date).getFullYear()}
</Badge>
)}
</div>
) : (
<div key={entity.id} className="relative">
<RideCard ride={entity} />
{entity.opening_date && (
<Badge className="absolute top-2 right-2 bg-green-500/90 text-white backdrop-blur-sm">
{new Date(entity.opening_date).getFullYear()}
</Badge>
)}
</div>
)
))}
</div>
)}
</TabsContent>
<TabsContent value="highest-rated-parks" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Highest Rated Parks</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Top-rated theme parks based on visitor reviews</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{highestRatedParks.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : highestRatedParks.data && highestRatedParks.data.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">
{highestRatedParks.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No rated parks available yet. Be the first to leave a review!
</div>
)}
</TabsContent>
<TabsContent value="highest-rated-rides" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Highest Rated Rides</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Top-rated attractions based on rider reviews</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{highestRatedRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : highestRatedRides.data && highestRatedRides.data.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">
{highestRatedRides.data.map((ride) => (
<RideCard key={ride.id} ride={ride} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No rated rides available yet. Be the first to leave a review!
</div>
)}
</TabsContent>
<TabsContent value="opening-soon" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Opening Soon</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides opening in the next 6 months</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{openingSoonParks.isLoading || openingSoonRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : openingSoon.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">
{openingSoon.map((entity: unknown) => {
const typedEntity = entity as { id: string; entityType: string; opening_date: string };
return typedEntity.entityType === 'park' ? (
<div key={typedEntity.id} className="relative">
<ParkCard park={entity as never} />
<Badge className="absolute top-2 right-2 bg-blue-500/90 text-white backdrop-blur-sm">
{new Date(typedEntity.opening_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Badge>
</div>
) : (
<div key={typedEntity.id} className="relative">
<RideCard ride={entity as never} />
<Badge className="absolute top-2 right-2 bg-blue-500/90 text-white backdrop-blur-sm">
{new Date(typedEntity.opening_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Badge>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No parks or rides scheduled to open in the next 6 months.
</div>
)}
</TabsContent>
<TabsContent value="closing-soon" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Closing Soon</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Last chance: Parks and rides closing in the next 6 months</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{closingSoonParks.isLoading || closingSoonRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : closingSoon.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">
{closingSoon.map((entity: unknown) => {
const typedEntity = entity as { id: string; entityType: string; closing_date: string };
return typedEntity.entityType === 'park' ? (
<div key={typedEntity.id} className="relative">
<ParkCard park={entity as never} />
<Badge className="absolute top-2 right-2 bg-red-500/90 text-white backdrop-blur-sm">
Closes {new Date(typedEntity.closing_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Badge>
</div>
) : (
<div key={typedEntity.id} className="relative">
<RideCard ride={entity as never} />
<Badge className="absolute top-2 right-2 bg-red-500/90 text-white backdrop-blur-sm">
Closes {new Date(typedEntity.closing_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Badge>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No parks or rides scheduled to close in the next 6 months.
</div>
)}
</TabsContent>
<TabsContent value="recently-closed" className="mt-8">
<div className="text-center mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2 bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(139,92,246,0.3)]">Recently Closed</h2>
<p className="text-muted-foreground text-sm md:text-base animate-fade-in">Parks and rides that closed in the last year</p>
<div className="mt-4 mx-auto w-24 h-1 bg-gradient-to-r from-transparent via-primary to-transparent rounded-full opacity-60"></div>
</div>
{recentlyClosedParks.isLoading || recentlyClosedRides.isLoading ? (
<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">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-72 bg-card rounded-lg border border-border animate-pulse" />
))}
</div>
) : recentlyClosed.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">
{recentlyClosed.map((entity: unknown) => {
const typedEntity = entity as { id: string; entityType: string; closing_date: string };
return typedEntity.entityType === 'park' ? (
<div key={typedEntity.id} className="relative">
<ParkCard park={entity as never} />
<Badge className="absolute top-2 right-2 bg-gray-500/90 text-white backdrop-blur-sm">
Closed {new Date(typedEntity.closing_date).getFullYear()}
</Badge>
</div>
) : (
<div key={typedEntity.id} className="relative">
<RideCard ride={entity as never} />
<Badge className="absolute top-2 right-2 bg-gray-500/90 text-white backdrop-blur-sm">
Closed {new Date(typedEntity.closing_date).getFullYear()}
</Badge>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No parks or rides closed in the last year.
</div>
)}
</TabsContent>
</Tabs>
</div>
</section>
);
}

View File

@@ -0,0 +1,189 @@
import { useState, useEffect } from 'react';
import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent, LucideIcon } from 'lucide-react';
import { formatLocationShort } from '@/lib/locationFormatter';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Park } from '@/types/database';
import { supabase } from '@/lib/supabaseClient';
import { getErrorMessage } from '@/lib/errorHandler';
export function FeaturedParks() {
const [topRatedParks, setTopRatedParks] = useState<Park[]>([]);
const [mostRidesParks, setMostRidesParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchFeaturedParks();
}, []);
const fetchFeaturedParks = async () => {
try {
// Fetch top rated parks
const { data: topRated } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*)
`)
.order('average_rating', { ascending: false })
.limit(3);
// Fetch parks with most rides
const { data: mostRides } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*)
`)
.order('ride_count', { ascending: false })
.limit(3);
setTopRatedParks(topRated || []);
setMostRidesParks(mostRides || []);
} catch (error: unknown) {
// Featured parks fetch failed - display empty sections
} finally {
setLoading(false);
}
};
const FeaturedParkCard = ({ park, icon: Icon, label }: { park: Park; icon: LucideIcon; label: string }) => (
<Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 cursor-pointer hover:scale-[1.02]">
<div className="relative">
{/* Gradient Background */}
<div className="aspect-video bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center relative">
<div className="opacity-50">
{park.park_type === 'theme_park' ? <Castle className="w-16 h-16" /> :
park.park_type === 'amusement_park' ? <FerrisWheel className="w-16 h-16" /> :
park.park_type === 'water_park' ? <Waves className="w-16 h-16" /> : <Tent className="w-16 h-16" />}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Featured Badge */}
<Badge className="absolute top-3 left-3 bg-primary/90 text-primary-foreground border-0">
<Icon className="w-3 h-3 mr-1" />
{label}
</Badge>
{/* Rating Badge */}
<Badge className="absolute top-3 right-3 bg-background/90 text-foreground border-0">
<Star className="w-3 h-3 mr-1 fill-yellow-400 text-yellow-400" />
{park.average_rating ? park.average_rating.toFixed(1) : 'N/A'}
</Badge>
</div>
<CardContent className="p-4">
<div className="space-y-2">
<h3 className="font-bold text-lg group-hover:text-primary transition-colors line-clamp-1">
{park.name}
</h3>
{park.location && (
<p className="text-sm text-muted-foreground">
{formatLocationShort(park.location)}
</p>
)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-primary font-medium">{park.ride_count} rides</span>
<div className="flex items-center gap-1">
<span className="text-accent font-medium">{park.coaster_count}</span>
<FerrisWheel className="w-3 h-3 text-accent" />
</div>
</div>
<div className="text-xs text-muted-foreground">
{park.review_count} reviews
</div>
</div>
</div>
</CardContent>
</div>
</Card>
);
if (loading) {
return (
<section className="py-12">
<div className="container mx-auto px-4">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</section>
);
}
return (
<section className="py-16 bg-muted/20">
<div className="container mx-auto px-4">
<div className="text-center max-w-3xl mx-auto mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Featured
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"> Destinations</span>
</h2>
<p className="text-xl text-muted-foreground">
Discover the highest-rated parks and thrill capitals around the world
</p>
</div>
{/* Top Rated Parks */}
<div className="mb-12">
<div className="flex items-center gap-3 mb-6">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center">
<Award className="w-4 h-4 text-primary" />
</div>
<h3 className="text-2xl font-bold">Top Rated Parks</h3>
</div>
<div className="grid md:grid-cols-3 gap-6">
{topRatedParks.map((park) => (
<FeaturedParkCard
key={park.id}
park={park}
icon={Award}
label="Top Rated"
/>
))}
</div>
</div>
{/* Most Rides */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-8 h-8 bg-secondary/20 rounded-full flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-secondary" />
</div>
<h3 className="text-2xl font-bold">Thrill Capitals</h3>
</div>
<div className="grid md:grid-cols-3 gap-6">
{mostRidesParks.map((park) => (
<FeaturedParkCard
key={park.id}
park={park}
icon={TrendingUp}
label="Most Rides"
/>
))}
</div>
</div>
{/* Call to Action */}
<div className="text-center mt-12">
<Button size="lg" variant="outline" className="border-primary/30 hover:bg-primary/10">
Explore All Parks
</Button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, MapPin, Calendar, Filter } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export function HeroSearch() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState('all');
const [selectedCountry, setSelectedCountry] = useState('all');
const popularSearches = [
'Cedar Point', 'Disney World', 'Europa-Park', 'Six Flags Magic Mountain',
'Alton Towers', 'Roller Coasters', 'Theme Parks', 'Water Parks'
];
const parkTypes = [
{ value: 'all', label: 'All Parks' },
{ value: 'theme_park', label: 'Theme Parks' },
{ value: 'amusement_park', label: 'Amusement Parks' },
{ value: 'water_park', label: 'Water Parks' }
];
const countries = [
{ value: 'all', label: 'All Countries' },
{ value: 'United States', label: 'United States' },
{ value: 'Germany', label: 'Germany' },
{ value: 'United Kingdom', label: 'United Kingdom' },
{ value: 'Netherlands', label: 'Netherlands' }
];
const handleSearch = () => {
// Search functionality handled by AutocompleteSearch component in Header
};
return (
<div className="relative max-w-4xl mx-auto">
{/* Main Search Card */}
<div className="bg-background/95 backdrop-blur-sm rounded-2xl border border-border/50 p-6 shadow-2xl shadow-primary/20">
<div className="space-y-4">
{/* Search Input Row */}
<div className="flex flex-col md:flex-row gap-3">
{/* Main Search */}
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted-foreground w-5 h-5" />
<Input
placeholder="Search parks, rides, or locations..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-12 pr-4 h-12 text-lg bg-muted/50 border-border/50 focus:border-primary/50 rounded-xl"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
{/* Park Type Filter */}
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-full md:w-48 h-12 bg-muted/50 border-border/50 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{parkTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Country Filter */}
<Select value={selectedCountry} onValueChange={setSelectedCountry}>
<SelectTrigger className="w-full md:w-48 h-12 bg-muted/50 border-border/50 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{countries.map((country) => (
<SelectItem key={country.value} value={country.value}>
{country.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Search Button */}
<Button
onClick={handleSearch}
className="h-12 px-8 bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 rounded-xl"
>
<Search className="w-5 h-5 md:mr-2" />
<span className="hidden md:inline">Search</span>
</Button>
</div>
{/* Quick Action Buttons */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="text-xs rounded-full">
<MapPin className="w-3 h-3 mr-1" />
Near Me
</Button>
<Button variant="outline" size="sm" className="text-xs rounded-full">
<Calendar className="w-3 h-3 mr-1" />
Open Today
</Button>
<Button variant="outline" size="sm" className="text-xs rounded-full">
<Filter className="w-3 h-3 mr-1" />
Advanced Filters
</Button>
</div>
</div>
</div>
{/* Popular Searches */}
<div className="mt-6 text-center">
<p className="text-sm text-muted-foreground mb-3">Popular searches:</p>
<div className="flex flex-wrap justify-center gap-2">
{popularSearches.map((search, index) => (
<Badge
key={index}
variant="secondary"
className="cursor-pointer hover:bg-primary/20 transition-colors"
onClick={() => setSearchTerm(search)}
>
{search}
</Badge>
))}
</div>
</div>
{/* Stats Row */}
<div className="mt-8 grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div className="bg-background/50 rounded-xl p-4 border border-border/30">
<div className="text-2xl font-bold text-primary">12+</div>
<div className="text-xs text-muted-foreground">Parks Listed</div>
</div>
<div className="bg-background/50 rounded-xl p-4 border border-border/30">
<div className="text-2xl font-bold text-secondary">500+</div>
<div className="text-xs text-muted-foreground">Rides Tracked</div>
</div>
<div className="bg-background/50 rounded-xl p-4 border border-border/30">
<div className="text-2xl font-bold text-accent">10+</div>
<div className="text-xs text-muted-foreground">Countries</div>
</div>
<div className="bg-background/50 rounded-xl p-4 border border-border/30">
<div className="text-2xl font-bold text-primary">50K+</div>
<div className="text-xs text-muted-foreground">Reviews</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { MapPin, Search, Star, TrendingUp, Globe, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
export function QuickActions() {
const actions = [
{
icon: MapPin,
title: 'Find Parks Near Me',
description: 'Discover theme parks in your area',
badge: 'Popular',
color: 'from-primary/20 to-primary/10',
borderColor: 'border-primary/20'
},
{
icon: Search,
title: 'Advanced Search',
description: 'Filter by rides, location, and ratings',
badge: 'New',
color: 'from-secondary/20 to-secondary/10',
borderColor: 'border-secondary/20'
},
{
icon: Star,
title: 'Top Rated Parks',
description: 'Browse the highest-rated destinations',
badge: 'Trending',
color: 'from-accent/20 to-accent/10',
borderColor: 'border-accent/20'
},
{
icon: TrendingUp,
title: 'Coaster Rankings',
description: 'See the world\'s best roller coasters',
badge: 'Hot',
color: 'from-primary/20 to-secondary/10',
borderColor: 'border-primary/20'
},
{
icon: Globe,
title: 'Browse by Country',
description: 'Explore parks around the world',
badge: null,
color: 'from-secondary/20 to-accent/10',
borderColor: 'border-secondary/20'
},
{
icon: Users,
title: 'Join Community',
description: 'Connect with fellow enthusiasts',
badge: 'Free',
color: 'from-accent/20 to-primary/10',
borderColor: 'border-accent/20'
}
];
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="text-center max-w-3xl mx-auto mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Quick
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"> Actions</span>
</h2>
<p className="text-xl text-muted-foreground">
Jump right into exploring with these popular features
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{actions.map((action, index) => (
<Card
key={index}
className={`group cursor-pointer border-0 bg-gradient-to-br ${action.color} ${action.borderColor} border hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:scale-[1.02]`}
>
<CardContent className="p-6 text-center space-y-4">
<div className="relative">
<div className="w-12 h-12 bg-background/80 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<action.icon className="w-6 h-6 text-foreground" />
</div>
{action.badge && (
<Badge
className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-xs px-2 py-1"
>
{action.badge}
</Badge>
)}
</div>
<div>
<h3 className="font-semibold text-lg mb-2 group-hover:text-primary transition-colors">
{action.title}
</h3>
<p className="text-sm text-muted-foreground">
{action.description}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="w-full bg-background/50 hover:bg-background/80 transition-colors"
>
Get Started
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,132 @@
import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Clock, User } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface RecentChangeCardProps {
entityType: 'park' | 'ride' | 'company';
entityId: string;
entityName: string;
entitySlug: string;
parkSlug?: string;
imageUrl?: string | null;
changeType: string;
changedAt: string;
changedByUsername?: string | null;
changedByAvatar?: string | null;
changeReason?: string | null;
}
const changeTypeColors = {
created: 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20',
updated: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
deleted: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
restored: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
archived: 'bg-muted text-muted-foreground border-muted',
};
const entityTypeColors = {
park: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
ride: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
company: 'bg-indigo-500/10 text-indigo-700 dark:text-indigo-400 border-indigo-500/20',
};
const formatEntityType = (type: string): string => {
return type
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const formatChangeType = (type: string): string => {
return type
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export function RecentChangeCard({
entityType,
entityId,
entityName,
entitySlug,
parkSlug,
imageUrl,
changeType,
changedAt,
changedByUsername,
changedByAvatar,
changeReason,
}: RecentChangeCardProps) {
const getEntityPath = () => {
if (entityType === 'park') return `/parks/${entitySlug}`;
if (entityType === 'ride') {
// For rides, use park slug if available, otherwise fallback to global rides list
if (parkSlug) {
return `/parks/${parkSlug}/rides/${entitySlug}`;
}
return `/rides`;
}
// Company paths - link to the appropriate company page
return '/';
};
return (
<Link to={getEntityPath()}>
<Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] h-full relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300">
{imageUrl && (
<div className="aspect-[3/2] w-full overflow-hidden bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative">
<img
src={imageUrl}
alt={entityName}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-80 group-hover:opacity-60 transition-opacity duration-300" />
</div>
)}
<div className="p-2.5 space-y-1.5 border-t border-border/30">
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={entityTypeColors[entityType]}>
{formatEntityType(entityType)}
</Badge>
<Badge variant="outline" className={changeTypeColors[changeType as keyof typeof changeTypeColors] || changeTypeColors.archived}>
{formatChangeType(changeType)}
</Badge>
</div>
<h3 className="font-bold line-clamp-2 text-sm group-hover:text-primary transition-all duration-300 group-hover:drop-shadow-[0_0_8px_rgba(139,92,246,0.5)]">{entityName}</h3>
</div>
{changeReason && (
<p className="text-xs text-muted-foreground line-clamp-2 italic">
{changeReason}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground gap-2">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span className="line-clamp-1">{formatDistanceToNow(new Date(changedAt), { addSuffix: true })}</span>
</div>
{changedByUsername && (
<div className="flex items-center gap-1">
<Avatar className="h-4 w-4">
<AvatarImage src={changedByAvatar || undefined} />
<AvatarFallback>
<User className="h-2 w-2" />
</AvatarFallback>
</Avatar>
<span className="line-clamp-1">{changedByUsername}</span>
</div>
)}
</div>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,32 @@
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
export function SimpleHeroSearch() {
return (
<section className="relative py-8 bg-gradient-to-br from-primary/10 via-secondary/5 to-accent/10">
<div className="container mx-auto px-4 text-center">
<div className="max-w-4xl mx-auto space-y-8">
<h1 className="text-5xl md:text-6xl font-bold leading-tight">
<span className="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent text-7xl">
ThrillWiki
</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
The ultimate theme park database. Discover parks, track rides, and connect with enthusiasts.
</p>
{/* Modern Autocomplete Search */}
<div className="max-w-2xl mx-auto">
<AutocompleteSearch
placeholder="Search parks, rides, or locations..."
variant="hero"
types={['park', 'ride', 'company']}
limit={6}
showRecentSearches={true}
/>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export function DiscordIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0189 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z"/>
</svg>
);
}

View File

@@ -0,0 +1,10 @@
export function GoogleIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path 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" fill="#4285F4"/>
<path 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" fill="#34A853"/>
<path 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" fill="#FBBC05"/>
<path 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" fill="#EA4335"/>
</svg>
);
}

View File

@@ -0,0 +1,117 @@
import { Shield, ArrowLeft, Settings, Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { RefreshButton } from '@/components/ui/refresh-button';
import { Link, useLocation } from 'react-router-dom';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
import { AuthButtons } from '@/components/auth/AuthButtons';
import { NotificationCenter } from '@/components/notifications/NotificationCenter';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
export function AdminHeader({ onRefresh, isRefreshing }: { onRefresh?: () => void; isRefreshing?: boolean }) {
const { permissions } = useUserRole();
const { user } = useAuth();
const location = useLocation();
const isMobile = useIsMobile();
const isSettingsPage = location.pathname === '/admin/settings';
const backLink = isSettingsPage ? '/admin' : '/';
const backText = isSettingsPage ? 'Back to Admin' : 'Back to ThrillWiki';
const pageTitle = isSettingsPage ? 'Admin Settings' : 'Admin Dashboard';
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4">
{/* Left Section - Navigation */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link to={backLink} className="flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">{backText}</span>
</Link>
</Button>
<div className="h-6 w-px bg-border hidden sm:block" />
<div className="flex items-center gap-2">
<Shield className="w-6 h-6 text-primary" />
<h1 className="text-lg font-semibold">
<span className="sm:hidden">Admin</span>
<span className="hidden sm:inline">{pageTitle}</span>
</h1>
</div>
</div>
{/* Right Section - Admin actions */}
<div className="flex items-center gap-2">
{/* Mobile Menu */}
<Sheet>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
<SheetHeader>
<SheetTitle>Admin Menu</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4 mt-6">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Theme</span>
<ThemeToggle />
</div>
<RefreshButton
onRefresh={onRefresh!}
isLoading={isRefreshing}
variant="ghost"
className="justify-start w-full"
/>
{permissions?.role_level === 'superuser' && !isSettingsPage && (
<Button variant="ghost" asChild className="justify-start">
<Link to="/admin/settings">
<Settings className="w-4 h-4 mr-2" />
Settings
</Link>
</Button>
)}
</div>
</SheetContent>
</Sheet>
{/* Desktop Actions */}
{onRefresh && (
<RefreshButton
onRefresh={onRefresh}
isLoading={isRefreshing}
variant="ghost"
size="sm"
className="hidden md:flex"
/>
)}
{permissions?.role_level === 'superuser' && !isSettingsPage && (
<Button variant="ghost" size="sm" asChild className="hidden md:flex">
<Link to="/admin/settings">
<Settings className="w-4 h-4" />
<span className="hidden sm:ml-2 sm:inline">Settings</span>
</Link>
</Button>
)}
<div className="hidden md:block">
<ThemeToggle />
</div>
{user && <NotificationCenter />}
<AuthButtons />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,58 @@
import { ReactNode } from 'react';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AdminSidebar } from './AdminSidebar';
import { AdminTopBar } from './AdminTopBar';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle } from 'lucide-react';
import { useSessionMonitor } from '@/hooks/useSessionMonitor';
interface AdminLayoutProps {
children: ReactNode;
onRefresh?: () => void;
refreshMode?: 'auto' | 'manual';
pollInterval?: number;
lastUpdated?: Date;
isRefreshing?: boolean;
}
export function AdminLayout({
children,
onRefresh,
refreshMode,
pollInterval,
lastUpdated,
isRefreshing
}: AdminLayoutProps) {
const { aalWarning } = useSessionMonitor();
return (
<SidebarProvider defaultOpen={true}>
<div className="flex min-h-screen w-full">
<AdminSidebar />
<main className="flex-1 flex flex-col">
<AdminTopBar
onRefresh={onRefresh}
refreshMode={refreshMode}
pollInterval={pollInterval}
lastUpdated={lastUpdated}
isRefreshing={isRefreshing}
/>
<div className="flex-1 overflow-y-auto bg-muted/30">
<div className="container mx-auto px-6 py-8 max-w-7xl">
{aalWarning && (
<Alert variant="destructive" className="mb-6">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Session Verification Required</AlertTitle>
<AlertDescription>
Your session requires re-verification. You will be redirected to verify your identity in 30 seconds.
</AlertDescription>
</Alert>
)}
{children}
</div>
</div>
</main>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,154 @@
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useUserRole } from '@/hooks/useUserRole';
import { useSidebar } from '@/hooks/useSidebar';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function AdminSidebar() {
const { state } = useSidebar();
const { permissions } = useUserRole();
const isSuperuser = permissions?.role_level === 'superuser';
const isAdmin = permissions?.role_level === 'admin' || isSuperuser;
const collapsed = state === 'collapsed';
const navItems = [
{
title: 'Dashboard',
url: '/admin',
icon: LayoutDashboard,
},
{
title: 'Moderation',
url: '/admin/moderation',
icon: FileText,
},
{
title: 'Reports',
url: '/admin/reports',
icon: Flag,
},
{
title: 'Inbox',
url: '/admin/contact',
icon: Inbox,
},
{
title: 'System Log',
url: '/admin/system-log',
icon: ScrollText,
},
{
title: 'Error Monitoring',
url: '/admin/error-monitoring',
icon: AlertTriangle,
},
{
title: 'Users',
url: '/admin/users',
icon: Users,
},
...(isAdmin ? [{
title: 'Blog',
url: '/admin/blog',
icon: BookOpen,
}] : []),
...(isSuperuser ? [{
title: 'Settings',
url: '/admin/settings',
icon: Settings,
}, {
title: 'Email Settings',
url: '/admin/email-settings',
icon: Mail,
}] : []),
];
return (
<Sidebar collapsible="icon">
<SidebarHeader className="border-b border-border/40 px-4 py-4">
<div className="flex items-center gap-2 min-h-[32px]">
<div className="flex items-center justify-center flex-shrink-0">
<img
src="https://cdn.thrillwiki.com/images/5d06b122-a3a3-47bc-6176-f93ad8f0ce00/favicon512"
alt="ThrillWiki"
width="32"
height="32"
loading="eager"
decoding="async"
draggable="false"
className={`
object-contain
transition-all duration-200 ease-in-out
${collapsed ? 'w-6 h-6' : 'w-8 h-8'}
`}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (!img.src.includes('favicon128')) {
img.src = 'https://cdn.thrillwiki.com/images/5d06b122-a3a3-47bc-6176-f93ad8f0ce00/favicon128';
}
}}
/>
</div>
{!collapsed && (
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-semibold truncate">ThrillWiki</span>
<span className="text-xs text-muted-foreground truncate">Admin Panel</span>
</div>
)}
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={collapsed ? item.title : undefined}>
<NavLink
to={item.url}
end={item.url === '/admin'}
className={({ isActive }) =>
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground'
}
>
<item.icon className="w-4 h-4" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border/40">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={collapsed ? 'Back to ThrillWiki' : undefined}>
<NavLink to="/" className="hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground">
<ArrowLeft className="w-4 h-4" />
{!collapsed && <span>Back to ThrillWiki</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,67 @@
import { RefreshCw } from 'lucide-react';
import { RefreshButton } from '@/components/ui/refresh-button';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
import { AuthButtons } from '@/components/auth/AuthButtons';
import { NotificationCenter } from '@/components/notifications/NotificationCenter';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAuth } from '@/hooks/useAuth';
interface AdminTopBarProps {
onRefresh?: () => void;
refreshMode?: 'auto' | 'manual';
pollInterval?: number;
lastUpdated?: Date;
isRefreshing?: boolean;
}
export function AdminTopBar({
onRefresh,
refreshMode,
pollInterval,
lastUpdated,
isRefreshing
}: AdminTopBarProps) {
const { user } = useAuth();
return (
<header className="sticky top-0 z-40 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4 gap-4">
{/* Left Section */}
<div className="flex items-center gap-3">
<SidebarTrigger className="-ml-1" />
{refreshMode && (
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3" />
{refreshMode === 'auto' ? (
<span>Auto: {pollInterval ? pollInterval / 1000 : 30}s</span>
) : (
<span>Manual</span>
)}
{lastUpdated && (
<span className="hidden md:inline">
{lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
)}
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
{onRefresh && (
<RefreshButton
onRefresh={onRefresh}
isLoading={isRefreshing}
variant="ghost"
size="sm"
/>
)}
<ThemeToggle />
{user && <NotificationCenter />}
<AuthButtons />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,47 @@
import { Link } from 'react-router-dom';
export function Footer() {
return (
<footer className="border-t border-border bg-background py-4">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row justify-between items-center gap-2 text-xs text-muted-foreground">
<div>
© {new Date().getFullYear()} ThrillWiki. All rights reserved.
</div>
<div className="flex items-center gap-4">
<Link
to="/contact"
className="hover:text-foreground transition-colors"
>
Contact
</Link>
<Link
to="/terms"
className="hover:text-foreground transition-colors"
>
Terms of Service
</Link>
<Link
to="/privacy"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
to="/submission-guidelines"
className="hover:text-foreground transition-colors"
>
Submission Guidelines
</Link>
<Link
to="/blog"
className="hover:text-foreground transition-colors"
>
Blog
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,256 @@
import { useState } from 'react';
import { Search, Menu, Sparkles, MapPin, Star, ChevronDown, Building, Users, Crown, Palette, Shield, FerrisWheel, Factory } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Link, useNavigate } from 'react-router-dom';
import { SearchDropdown } from '@/components/search/SearchDropdown';
import { MobileSearch } from '@/components/search/MobileSearch';
import { AuthButtons } from '@/components/auth/AuthButtons';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
import { NotificationCenter } from '@/components/notifications/NotificationCenter';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { useIsMobile } from '@/hooks/use-mobile';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ui/navigation-menu";
export function Header() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const { user } = useAuth();
const { isModerator, loading: rolesLoading } = useUserRole();
const isMobile = useIsMobile();
return (
<>
<header className="sticky top-0 z-40 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 md:h-16 items-center justify-between gap-2 md:gap-4 px-3 md:px-4">
{/* Mobile: Menu + Logo */}
<div className="flex items-center gap-2 md:gap-6 flex-1 md:flex-initial min-w-0">
{/* Mobile Menu */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden h-9 w-9">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] sm:w-[320px]">
<nav className="flex flex-col gap-1 mt-8">
<div className="mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2 px-3">
Explore
</h3>
</div>
<Link
to="/rides"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Rides
</Link>
<Link
to="/parks"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Parks
</Link>
<Link
to="/manufacturers"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Manufacturers
</Link>
<Link
to="/designers"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Designers
</Link>
<Link
to="/operators"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Operators
</Link>
<Link
to="/owners"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Property Owners
</Link>
{!rolesLoading && isModerator() && (
<>
<div className="my-2 border-t border-border" />
<Link
to="/admin"
className="px-3 py-2.5 text-base font-medium hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Admin
</Link>
</>
)}
<div className="my-2 border-t border-border" />
<div className="px-3 py-2.5 flex items-center justify-between">
<span className="text-base font-medium">Theme</span>
<ThemeToggle />
</div>
</nav>
</SheetContent>
</Sheet>
{/* Logo */}
<Link to="/" className="flex items-center hover:opacity-80 transition-opacity flex-shrink-0">
<span className="font-bold text-base sm:text-lg md:text-xl bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent whitespace-nowrap">
ThrillWiki
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-1">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger className="h-9">Explore</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid min-w-[320px] max-w-[500px] w-fit gap-3 p-4">
<li>
<NavigationMenuLink asChild>
<Link
to="/rides"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Rides</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
Discover exciting rides and attractions
</p>
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link
to="/parks"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Parks</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
Browse theme parks around the world
</p>
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link
to="/manufacturers"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Manufacturers</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
Explore ride manufacturers
</p>
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link
to="/designers"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Designers</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
View ride designers
</p>
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link
to="/operators"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Operators</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
Find park operators
</p>
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link
to="/owners"
className="block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent/20 focus:bg-accent/20"
>
<div className="text-sm font-medium leading-none">Property Owners</div>
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
View property owners
</p>
</Link>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
{!rolesLoading && isModerator() && (
<Button variant="ghost" size="sm" className="h-9" asChild>
<Link to="/admin">Admin</Link>
</Button>
)}
</nav>
</div>
{/* Right side: Search, Theme, Auth */}
<div className="flex items-center gap-1.5 md:gap-2 flex-shrink-0">
{/* Desktop Search */}
<div className="hidden lg:block lg:w-64 xl:w-80 min-w-0">
<SearchDropdown />
</div>
{/* Mobile Search Button */}
<Button
variant="ghost"
size="icon"
className="lg:hidden h-9 w-9 min-w-[36px]"
onClick={() => setMobileSearchOpen(true)}
>
<Search className="h-5 w-5" />
<span className="sr-only">Search</span>
</Button>
<div className="hidden md:block">
<ThemeToggle />
</div>
{user && <NotificationCenter />}
<AuthButtons />
</div>
</div>
</header>
{/* Mobile Search Modal */}
<MobileSearch open={mobileSearchOpen} onOpenChange={setMobileSearchOpen} />
</>
);
}

View File

@@ -0,0 +1,61 @@
import { ReactNode } from 'react';
import { NetworkErrorBanner } from '@/components/error/NetworkErrorBanner';
import { SubmissionQueueIndicator } from '@/components/submission/SubmissionQueueIndicator';
import { useNetworkStatus } from '@/hooks/useNetworkStatus';
import { useSubmissionQueue } from '@/hooks/useSubmissionQueue';
interface ResilienceProviderProps {
children: ReactNode;
}
/**
* ResilienceProvider wraps the app with network error handling
* and submission queue management UI
*/
export function ResilienceProvider({ children }: ResilienceProviderProps) {
const { isOnline } = useNetworkStatus();
const {
queuedItems,
lastSyncTime,
nextRetryTime,
retryItem,
retryAll,
removeItem,
clearQueue,
} = useSubmissionQueue({
autoRetry: true,
retryDelayMs: 5000,
maxRetries: 3,
});
return (
<>
{/* Network Error Banner - Shows at top when offline or errors present */}
<NetworkErrorBanner
isOffline={!isOnline}
pendingCount={queuedItems.length}
onRetryNow={retryAll}
estimatedRetryTime={nextRetryTime || undefined}
/>
{/* Main Content */}
<div className="min-h-screen">
{children}
</div>
{/* Floating Queue Indicator - Shows in bottom right */}
{queuedItems.length > 0 && (
<div className="fixed bottom-6 right-6 z-40">
<SubmissionQueueIndicator
queuedItems={queuedItems}
lastSyncTime={lastSyncTime || undefined}
onRetryItem={retryItem}
onRetryAll={retryAll}
onRemoveItem={removeItem}
onClearQueue={clearQueue}
/>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,158 @@
import { useState, useEffect } from "react";
import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database";
import { supabase } from "@/lib/supabaseClient";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { handleError } from "@/lib/errorHandler";
interface ListDisplayProps {
list: UserTopList;
}
interface EnrichedListItem extends UserTopListItem {
entity?: Park | Ride | Company;
}
export function ListDisplay({ list }: ListDisplayProps) {
const [items, setItems] = useState<EnrichedListItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchItemsWithEntities();
}, [list.id]);
const fetchItemsWithEntities = async () => {
setLoading(true);
// First, get the list items
const { data: itemsData, error: itemsError } = await supabase
.from("user_top_list_items")
.select("*")
.eq("list_id", list.id)
.order("position", { ascending: true });
if (itemsError) {
handleError(itemsError, {
action: 'Fetch List Items',
metadata: { listId: list.id }
});
setLoading(false);
return;
}
// Then, fetch the entities for each item
const enrichedItems = await Promise.all(
(itemsData as UserTopListItem[]).map(async (item) => {
let entity: Park | Ride | Company | null = null;
if (item.entity_type === "park") {
const { data } = await supabase
.from("parks")
.select("id, name, slug, park_type, location_id")
.eq("id", item.entity_id)
.single();
entity = data as Park | null;
} else if (item.entity_type === "ride") {
const { data } = await supabase
.from("rides")
.select("id, name, slug, category, park_id")
.eq("id", item.entity_id)
.single();
entity = data as Ride | null;
} else if (item.entity_type === "company") {
const { data } = await supabase
.from("companies")
.select("id, name, slug, company_type")
.eq("id", item.entity_id)
.single();
entity = data as Company | null;
}
return { ...item, entity };
})
);
setItems(enrichedItems as EnrichedListItem[]);
setLoading(false);
};
const getEntityUrl = (item: EnrichedListItem) => {
if (!item.entity) return "#";
const entity = item.entity as { slug?: string };
if (item.entity_type === "park") {
return `/parks/${entity.slug}`;
} else if (item.entity_type === "ride") {
// We need park slug for rides
return `/rides/${entity.slug}`;
} else if (item.entity_type === "company") {
return `/companies/${entity.slug}`;
}
return "#";
};
if (loading) {
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
This list is empty. Click "Edit" to add items.
</div>
);
}
return (
<ol className="space-y-2">
{items.map((item, index) => (
<li key={item.id} className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
<span className="font-bold text-lg text-muted-foreground min-w-[2rem]">
{index + 1}.
</span>
<div className="flex-1">
{item.entity ? (
<Link
to={getEntityUrl(item)}
className="font-medium hover:underline"
>
{(item.entity as { name?: string }).name || 'Unknown'}
</Link>
) : (
<span className="font-medium text-muted-foreground">
[Deleted or unavailable]
</span>
)}
<div className="flex gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{item.entity_type}
</Badge>
{item.entity && item.entity_type === "park" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Park).park_type}
</Badge>
)}
{item.entity && item.entity_type === "ride" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Ride).category}
</Badge>
)}
{item.entity && item.entity_type === "company" && (
<Badge variant="outline" className="text-xs">
{(item.entity as Company).company_type}
</Badge>
)}
</div>
{item.notes && (
<p className="text-sm text-muted-foreground mt-2 italic">
"{item.notes}"
</p>
)}
</div>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from "react";
import { UserTopList, UserTopListItem } from "@/types/database";
import { supabase } from "@/lib/supabaseClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { GripVertical, Trash2, Plus } from "lucide-react";
import { toast } from "sonner";
import { ListSearch } from "./ListSearch";
import { getErrorMessage } from "@/lib/errorHandler";
interface ListItemEditorProps {
list: UserTopList;
onUpdate: () => void;
onClose: () => void;
}
export function ListItemEditor({ list, onUpdate, onClose }: ListItemEditorProps) {
const [items, setItems] = useState<UserTopListItem[]>([]);
const [loading, setLoading] = useState(true);
const [showSearch, setShowSearch] = useState(false);
useEffect(() => {
fetchItems();
}, [list.id]);
const fetchItems = async () => {
setLoading(true);
const { data, error } = await supabase
.from("user_top_list_items")
.select("*")
.eq("list_id", list.id)
.order("position", { ascending: true });
if (error) {
const errorMessage = getErrorMessage(error);
toast.error("Failed to load list items", {
description: errorMessage
});
} else {
setItems(data as UserTopListItem[]);
}
setLoading(false);
};
const handleAddItem = async (entityType: string, entityId: string, entityName: string) => {
const newPosition = items.length + 1;
const { error } = await supabase
.from("user_top_list_items")
.insert({
list_id: list.id,
entity_type: entityType,
entity_id: entityId,
position: newPosition,
});
if (error) {
if (error.code === "23505") {
toast.error("This item is already in your list");
} else {
const errorMessage = getErrorMessage(error);
toast.error("Failed to add item", {
description: errorMessage
});
}
} else {
toast.success(`Added ${entityName} to list`);
fetchItems();
onUpdate();
setShowSearch(false);
}
};
const handleRemoveItem = async (itemId: string) => {
const { error } = await supabase
.from("user_top_list_items")
.delete()
.eq("id", itemId);
if (error) {
const errorMessage = getErrorMessage(error);
toast.error("Failed to remove item", {
description: errorMessage
});
} else {
toast.success("Item removed");
// Reorder remaining items
const remainingItems = items.filter(i => i.id !== itemId);
await reorderItems(remainingItems);
fetchItems();
onUpdate();
}
};
const handleUpdateNotes = async (itemId: string, notes: string) => {
const { error } = await supabase
.from("user_top_list_items")
.update({ notes })
.eq("id", itemId);
if (error) {
const errorMessage = getErrorMessage(error);
toast.error("Failed to update notes", {
description: errorMessage
});
} else {
setItems(items.map(i => i.id === itemId ? { ...i, notes } : i));
}
};
const handleMoveItem = async (itemId: string, direction: "up" | "down") => {
const currentIndex = items.findIndex(i => i.id === itemId);
if (
(direction === "up" && currentIndex === 0) ||
(direction === "down" && currentIndex === items.length - 1)
) {
return;
}
const newItems = [...items];
const swapIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
[newItems[currentIndex], newItems[swapIndex]] = [newItems[swapIndex], newItems[currentIndex]];
await reorderItems(newItems);
setItems(newItems);
onUpdate();
};
const reorderItems = async (orderedItems: UserTopListItem[]) => {
const updates = orderedItems.map((item, index) => ({
id: item.id,
position: index + 1,
}));
for (const update of updates) {
await supabase
.from("user_top_list_items")
.update({ position: update.position })
.eq("id", update.id);
}
};
if (loading) {
return <div className="text-center py-4">Loading items...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-semibold">Edit List Items</h3>
<div className="space-x-2">
<Button size="sm" onClick={() => setShowSearch(!showSearch)}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
<Button size="sm" variant="outline" onClick={onClose}>
Done
</Button>
</div>
</div>
{showSearch && (
<ListSearch
listType={list.list_type}
onSelect={handleAddItem}
onClose={() => setShowSearch(false)}
/>
)}
{items.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No items in this list yet. Click "Add Item" to get started.
</div>
) : (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={item.id}
className="flex items-start gap-2 p-3 border rounded-lg bg-card"
>
<div className="flex flex-col gap-1 mt-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveItem(item.id, "up")}
disabled={index === 0}
>
<GripVertical className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground text-center">
{index + 1}
</span>
</div>
<div className="flex-1 space-y-2">
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{item.entity_type} - {item.entity_id}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveItem(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div>
<Label htmlFor={`notes-${item.id}`} className="text-xs">
Notes (optional)
</Label>
<Textarea
id={`notes-${item.id}`}
value={item.notes || ""}
onChange={(e) => handleUpdateNotes(item.id, e.target.value)}
placeholder="Add personal notes about this item..."
className="h-16 text-sm"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabaseClient";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Plus, X } from "lucide-react";
import { useDebounce } from "@/hooks/useDebounce";
import { Badge } from "@/components/ui/badge";
interface ListSearchProps {
listType: string;
onSelect: (entityType: string, entityId: string, entityName: string) => void;
onClose: () => void;
}
interface SearchResult {
id: string;
name: string;
type: "park" | "ride" | "company";
subtitle?: string;
}
export function ListSearch({ listType, onSelect, onClose }: ListSearchProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.length >= 2) {
searchEntities();
} else {
setResults([]);
}
}, [debouncedQuery, listType]);
const searchEntities = async () => {
setLoading(true);
const searchResults: SearchResult[] = [];
// Determine which entity types to search based on list type
const shouldSearchParks = listType === "parks" || listType === "mixed";
const shouldSearchRides = listType === "rides" || listType === "coasters" || listType === "mixed";
const shouldSearchCompanies = listType === "companies" || listType === "mixed";
// Search parks
if (shouldSearchParks) {
const { data: parks } = await supabase
.from("parks")
.select("id, name, park_type, location_id")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (parks) {
searchResults.push(
...parks.map((park) => ({
id: park.id,
name: park.name,
type: "park" as const,
subtitle: park.park_type,
}))
);
}
}
// Search rides
if (shouldSearchRides) {
const { data: rides } = await supabase
.from("rides")
.select("id, name, category, park:parks(name)")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (rides) {
interface RideSearchResult {
id: string;
name: string;
park?: { name: string } | null;
category?: string | null;
}
searchResults.push(
...rides.map((ride: RideSearchResult) => ({
id: ride.id,
name: ride.name,
type: "ride" as const,
subtitle: ride.park?.name || ride.category || 'Unknown',
}))
);
}
}
// Search companies
if (shouldSearchCompanies) {
const { data: companies } = await supabase
.from("companies")
.select("id, name, company_type")
.ilike("name", `%${debouncedQuery}%`)
.limit(10);
if (companies) {
searchResults.push(
...companies.map((company) => ({
id: company.id,
name: company.name,
type: "company" as const,
subtitle: company.company_type,
}))
);
}
}
setResults(searchResults);
setLoading(false);
};
return (
<div className="border rounded-lg p-4 bg-card space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for parks, rides, or companies..."
className="pl-9"
/>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{loading && (
<div className="text-center py-4 text-muted-foreground">
Searching...
</div>
)}
{!loading && results.length === 0 && debouncedQuery.length >= 2 && (
<div className="text-center py-4 text-muted-foreground">
No results found. Try a different search term.
</div>
)}
{!loading && results.length > 0 && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{results.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="flex items-center justify-between p-2 rounded hover:bg-muted transition-colors"
>
<div className="flex-1">
<p className="font-medium">{result.name}</p>
<div className="flex gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{result.type}
</Badge>
{result.subtitle && (
<Badge variant="outline" className="text-xs">
{result.subtitle}
</Badge>
)}
</div>
</div>
<Button
size="sm"
onClick={() => onSelect(result.type, result.id, result.name)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
))}
</div>
)}
{debouncedQuery.length < 2 && (
<div className="text-center py-4 text-muted-foreground text-sm">
Type at least 2 characters to search
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,330 @@
import { useState, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/lib/supabaseClient";
import { UserTopList, UserTopListItem } from "@/types/database";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Trash2, Edit, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { ListItemEditor } from "./ListItemEditor";
import { ListDisplay } from "./ListDisplay";
import { handleError } from "@/lib/errorHandler";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export function UserListManager() {
const { user } = useAuth();
const [lists, setLists] = useState<UserTopList[]>([]);
const [loading, setLoading] = useState(true);
const [editingList, setEditingList] = useState<UserTopList | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [newListTitle, setNewListTitle] = useState("");
const [newListDescription, setNewListDescription] = useState("");
const [newListType, setNewListType] = useState<string>("mixed");
const [newListIsPublic, setNewListIsPublic] = useState(true);
useEffect(() => {
if (user) {
fetchLists();
}
}, [user]);
const fetchLists = async () => {
if (!user) return;
setLoading(true);
const { data, error } = await supabase
.from("user_top_lists")
.select(`
*,
list_items (
id,
entity_type,
entity_id,
position,
notes,
created_at,
updated_at
)
`)
.eq("user_id", user.id)
.order("created_at", { ascending: false });
if (error) {
handleError(error, {
action: 'Load User Lists',
userId: user.id
});
} else {
// Map Supabase data to UserTopList interface
const mappedLists: UserTopList[] = (data || []).map(list => ({
id: list.id,
user_id: list.user_id,
title: list.title,
description: list.description,
list_type: list.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
is_public: list.is_public,
created_at: list.created_at,
updated_at: list.updated_at,
items: (list.list_items || []).map(item => ({
id: item.id,
list_id: list.id, // Add the parent list ID
entity_type: item.entity_type as 'park' | 'ride' | 'company',
entity_id: item.entity_id,
position: item.position,
notes: item.notes,
created_at: item.created_at || new Date().toISOString(),
updated_at: item.updated_at || new Date().toISOString(),
})),
}));
setLists(mappedLists);
}
setLoading(false);
};
const handleCreateList = async () => {
if (!user || !newListTitle.trim()) {
toast.error("Please enter a list title");
return;
}
const { data, error } = await supabase
.from("user_top_lists")
.insert([{
user_id: user.id,
title: newListTitle,
description: newListDescription || null,
list_type: newListType,
is_public: newListIsPublic,
}])
.select()
.single();
if (error) {
handleError(error, {
action: 'Create List',
userId: user.id,
metadata: { title: newListTitle }
});
} else {
toast.success("List created successfully");
const newList: UserTopList = {
id: data.id,
user_id: data.user_id,
title: data.title,
description: data.description,
list_type: data.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
is_public: data.is_public,
created_at: data.created_at,
updated_at: data.updated_at,
items: [],
};
setLists([newList, ...lists]);
setIsCreateDialogOpen(false);
setNewListTitle("");
setNewListDescription("");
setNewListType("mixed");
setNewListIsPublic(true);
}
};
const handleDeleteList = async (listId: string) => {
const { error } = await supabase
.from("user_top_lists")
.delete()
.eq("id", listId);
if (error) {
handleError(error, {
action: 'Delete List',
userId: user?.id,
metadata: { listId }
});
} else {
toast.success("List deleted");
setLists(lists.filter(l => l.id !== listId));
}
};
const handleToggleVisibility = async (list: UserTopList) => {
const { error } = await supabase
.from("user_top_lists")
.update({ is_public: !list.is_public })
.eq("id", list.id);
if (error) {
handleError(error, {
action: 'Toggle List Visibility',
userId: user?.id,
metadata: { listId: list.id }
});
} else {
toast.success(`List is now ${!list.is_public ? "public" : "private"}`);
setLists(lists.map(l =>
l.id === list.id ? { ...l, is_public: !l.is_public } : l
));
}
};
if (loading) {
return <div className="text-center py-8">Loading lists...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">My Lists</h2>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create List
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New List</DialogTitle>
<DialogDescription>
Create a new list to organize your favorite parks, rides, or companies.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="My Top 10 Coasters"
/>
</div>
<div>
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={newListDescription}
onChange={(e) => setNewListDescription(e.target.value)}
placeholder="A list of my all-time favorite roller coasters"
/>
</div>
<div>
<Label htmlFor="type">List Type</Label>
<Select value={newListType} onValueChange={setNewListType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="parks">Parks</SelectItem>
<SelectItem value="rides">Rides</SelectItem>
<SelectItem value="coasters">Coasters</SelectItem>
<SelectItem value="companies">Companies</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Switch
id="public"
checked={newListIsPublic}
onCheckedChange={setNewListIsPublic}
/>
<Label htmlFor="public">Make this list public</Label>
</div>
<Button onClick={handleCreateList} className="w-full">
Create List
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{lists.length === 0 ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground mb-4">You haven't created any lists yet.</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Your First List
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{lists.map((list) => (
<Card key={list.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>{list.title}</CardTitle>
{list.description && (
<CardDescription>{list.description}</CardDescription>
)}
<div className="flex gap-2 mt-2">
<span className="text-xs bg-secondary px-2 py-1 rounded">
{list.list_type}
</span>
<span className="text-xs bg-secondary px-2 py-1 rounded">
{list.items?.length || 0} items
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleVisibility(list)}
title={list.is_public ? "Make private" : "Make public"}
>
{list.is_public ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingList(list)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteList(list.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{editingList?.id === list.id ? (
<ListItemEditor
list={list}
onUpdate={fetchLists}
onClose={() => setEditingList(null)}
/>
) : (
<ListDisplay list={list} />
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More