Files
thrilltrack-explorer/src/components/profile/RideCreditsManager.tsx
2025-10-16 15:43:52 +00:00

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