mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:31:13 -05:00
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Plus, LayoutGrid, List, GripVertical } from 'lucide-react';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { toast } from 'sonner';
|
|
import { getErrorMessage } from '@/lib/errorHandler';
|
|
import { AddRideCreditDialog } from './AddRideCreditDialog';
|
|
import { RideCreditCard } from './RideCreditCard';
|
|
import { SortableRideCreditCard } from './SortableRideCreditCard';
|
|
import { RideCreditFilters } from './RideCreditFilters';
|
|
import { UserRideCredit } from '@/types/database';
|
|
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
closestCenter,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
arrayMove,
|
|
} from '@dnd-kit/sortable';
|
|
|
|
interface RideCreditsManagerProps {
|
|
userId: string;
|
|
}
|
|
|
|
export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
|
const [credits, setCredits] = useState<UserRideCredit[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
const [sortBy, setSortBy] = useState<'date' | 'count' | 'name' | 'custom'>('custom');
|
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
|
|
// Use the filter hook
|
|
const {
|
|
filters,
|
|
updateFilter,
|
|
clearFilters,
|
|
filteredCredits,
|
|
activeFilterCount,
|
|
} = useRideCreditFilters(credits);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
})
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchCredits();
|
|
}, [userId, sortBy]);
|
|
|
|
const fetchCredits = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Build main query with enhanced data
|
|
let query = supabase
|
|
.from('user_ride_credits')
|
|
.select(`
|
|
*,
|
|
rides:ride_id (
|
|
id,
|
|
name,
|
|
slug,
|
|
category,
|
|
status,
|
|
coaster_type,
|
|
seating_type,
|
|
intensity_level,
|
|
max_speed_kmh,
|
|
max_height_meters,
|
|
length_meters,
|
|
inversions,
|
|
card_image_url,
|
|
parks:park_id (
|
|
id,
|
|
name,
|
|
slug,
|
|
park_type,
|
|
locations:location_id (
|
|
country,
|
|
state_province,
|
|
city
|
|
)
|
|
),
|
|
manufacturer:manufacturer_id (
|
|
id,
|
|
name
|
|
),
|
|
designer:designer_id (
|
|
id,
|
|
name
|
|
)
|
|
)
|
|
`)
|
|
.eq('user_id', userId);
|
|
|
|
// Apply sorting - default to sort_order when available
|
|
if (sortBy === 'date') {
|
|
query = query.order('first_ride_date', { ascending: false, nullsFirst: false });
|
|
} else if (sortBy === 'count') {
|
|
query = query.order('ride_count', { ascending: false });
|
|
} else if (sortBy === 'name') {
|
|
query = query.order('created_at', { ascending: false });
|
|
} else {
|
|
query = query.order('sort_order', { ascending: true, nullsFirst: false });
|
|
}
|
|
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
|
|
let processedData = (data || []) as any[];
|
|
|
|
// Sort by name client-side if needed
|
|
if (sortBy === 'name') {
|
|
processedData.sort((a, b) => {
|
|
const nameA = a.rides?.name || '';
|
|
const nameB = b.rides?.name || '';
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
}
|
|
|
|
setCredits(processedData);
|
|
} catch (error) {
|
|
console.error('Error fetching ride credits:', error);
|
|
toast.error(getErrorMessage(error));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreditAdded = async (newCreditId: string) => {
|
|
try {
|
|
// Fetch just the new credit with all necessary joins
|
|
const { data, error } = await supabase
|
|
.from('user_ride_credits')
|
|
.select(`
|
|
*,
|
|
rides:ride_id (
|
|
id,
|
|
name,
|
|
slug,
|
|
category,
|
|
status,
|
|
coaster_type,
|
|
seating_type,
|
|
intensity_level,
|
|
max_speed_kmh,
|
|
max_height_meters,
|
|
length_meters,
|
|
inversions,
|
|
card_image_url,
|
|
parks:park_id (
|
|
id,
|
|
name,
|
|
slug,
|
|
park_type,
|
|
locations:location_id (
|
|
country,
|
|
state_province,
|
|
city
|
|
)
|
|
),
|
|
manufacturer:manufacturer_id (
|
|
id,
|
|
name
|
|
),
|
|
designer:designer_id (
|
|
id,
|
|
name
|
|
)
|
|
)
|
|
`)
|
|
.eq('id', newCreditId)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
// Add to existing array
|
|
setCredits(prev => [...prev, data as any]);
|
|
setIsAddDialogOpen(false);
|
|
} catch (error) {
|
|
console.error('Error fetching new credit:', error);
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleCreditUpdated = (creditId: string, updates: Partial<UserRideCredit>) => {
|
|
// Optimistic update - update only the affected credit
|
|
setCredits(prev => prev.map(c =>
|
|
c.id === creditId ? { ...c, ...updates } : c
|
|
));
|
|
};
|
|
|
|
const handleCreditDeleted = async (creditId: string) => {
|
|
// Store for rollback
|
|
const deletedCredit = credits.find(c => c.id === creditId);
|
|
|
|
// Optimistic removal
|
|
setCredits(prev => prev.filter(c => c.id !== creditId));
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('user_ride_credits')
|
|
.delete()
|
|
.eq('id', creditId)
|
|
.eq('user_id', userId);
|
|
|
|
if (error) throw error;
|
|
|
|
toast.success('Ride credit removed');
|
|
} catch (error) {
|
|
console.error('Error deleting credit:', error);
|
|
toast.error(getErrorMessage(error));
|
|
|
|
// Rollback on error
|
|
if (deletedCredit) {
|
|
setCredits(prev => [...prev, deletedCredit]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReorder = async (creditId: string, newPosition: number) => {
|
|
try {
|
|
const { error } = await supabase.rpc('reorder_ride_credit', {
|
|
p_credit_id: creditId,
|
|
p_new_position: newPosition
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
// No refetch - optimistic update is already applied
|
|
} catch (error) {
|
|
console.error('Error reordering credit:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const oldIndex = credits.findIndex(c => c.id === String(active.id));
|
|
const newIndex = credits.findIndex(c => c.id === String(over.id));
|
|
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
|
|
// Store old order for rollback
|
|
const oldCredits = credits;
|
|
|
|
// Optimistic update
|
|
const newCredits = arrayMove(credits, oldIndex, newIndex);
|
|
setCredits(newCredits);
|
|
|
|
try {
|
|
await handleReorder(String(active.id), newIndex + 1);
|
|
toast.success('Order updated');
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
// Rollback to old order
|
|
setCredits(oldCredits);
|
|
}
|
|
};
|
|
|
|
// Memoize statistics to only recalculate when credits change
|
|
const stats = useMemo(() => ({
|
|
totalRides: credits.reduce((sum, c) => sum + c.ride_count, 0),
|
|
uniqueCredits: credits.length,
|
|
parksVisited: new Set(credits.map(c => c.rides?.parks?.id).filter(Boolean)).size,
|
|
coasters: credits.filter(c => c.rides?.category === 'roller_coaster').length
|
|
}), [credits]);
|
|
|
|
// Use filtered credits for display
|
|
const displayCredits = filteredCredits;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 6 }, (_, i) => (
|
|
<Card key={i} className="animate-pulse">
|
|
<CardContent className="p-6">
|
|
<div className="h-32 bg-muted rounded"></div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Statistics */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-2xl font-bold">{stats.totalRides}</div>
|
|
<div className="text-sm text-muted-foreground">Total Rides</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-2xl font-bold">{stats.uniqueCredits}</div>
|
|
<div className="text-sm text-muted-foreground">Unique Credits</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-2xl font-bold">{stats.parksVisited}</div>
|
|
<div className="text-sm text-muted-foreground">Parks Visited</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-2xl font-bold">{stats.coasters}</div>
|
|
<div className="text-sm text-muted-foreground">Coasters</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<RideCreditFilters
|
|
filters={filters}
|
|
onFilterChange={updateFilter}
|
|
onClearFilters={clearFilters}
|
|
credits={credits}
|
|
activeFilterCount={activeFilterCount}
|
|
resultCount={displayCredits.length}
|
|
totalCount={credits.length}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Controls */}
|
|
<div className="flex flex-wrap gap-4 items-center justify-between">
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => setIsAddDialogOpen(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Credit
|
|
</Button>
|
|
|
|
<Button
|
|
variant={isEditMode ? 'default' : 'outline'}
|
|
onClick={() => setIsEditMode(!isEditMode)}
|
|
>
|
|
<GripVertical className="w-4 h-4 mr-2" />
|
|
{isEditMode ? 'Done Editing' : 'Edit Order'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Select value={sortBy} onValueChange={(value) => setSortBy(value as typeof sortBy)}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Sort by" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="custom">Custom Order</SelectItem>
|
|
<SelectItem value="date">Most Recent</SelectItem>
|
|
<SelectItem value="count">Most Ridden</SelectItem>
|
|
<SelectItem value="name">Ride Name</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="flex border rounded-md">
|
|
<Button
|
|
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
|
size="icon"
|
|
onClick={() => setViewMode('grid')}
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
|
size="icon"
|
|
onClick={() => setViewMode('list')}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits Display */}
|
|
{displayCredits.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-12">
|
|
<div className="text-center">
|
|
{credits.length === 0 ? (
|
|
<>
|
|
<h3 className="text-xl font-semibold mb-2">No Ride Credits Yet</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Start tracking your ride experiences
|
|
</p>
|
|
<Button onClick={() => setIsAddDialogOpen(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Your First Credit
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h3 className="text-xl font-semibold mb-2">No Results Found</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Try adjusting your filters
|
|
</p>
|
|
<Button onClick={clearFilters} variant="outline">
|
|
Clear Filters
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : isEditMode ? (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={displayCredits.map(c => c.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className={viewMode === 'grid'
|
|
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
|
: 'space-y-4'
|
|
}>
|
|
{displayCredits.map((credit, index) => (
|
|
<SortableRideCreditCard
|
|
key={credit.id}
|
|
credit={credit}
|
|
position={index + 1}
|
|
maxPosition={displayCredits.length}
|
|
viewMode={viewMode}
|
|
onUpdate={handleCreditUpdated}
|
|
onDelete={() => handleCreditDeleted(credit.id)}
|
|
onReorder={handleReorder}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
) : (
|
|
<div className={viewMode === 'grid'
|
|
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
|
: 'space-y-4'
|
|
}>
|
|
{displayCredits.map((credit, index) => (
|
|
<RideCreditCard
|
|
key={credit.id}
|
|
credit={credit}
|
|
position={index + 1}
|
|
viewMode={viewMode}
|
|
onUpdate={handleCreditUpdated}
|
|
onDelete={() => handleCreditDeleted(credit.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Credit Dialog */}
|
|
<AddRideCreditDialog
|
|
userId={userId}
|
|
open={isAddDialogOpen}
|
|
onOpenChange={setIsAddDialogOpen}
|
|
onSuccess={handleCreditAdded}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|