mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 01:31:12 -05:00
503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
import { useState } from 'react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Calendar, MapPin, Edit, Trash2, Plus, Minus, Check, X, Star, Factory, Gauge, Ruler, Zap } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import { format } from 'date-fns';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { toast } from 'sonner';
|
|
import { getErrorMessage } from '@/lib/errorHandler';
|
|
import { UserRideCredit } from '@/types/database';
|
|
import { convertValueFromMetric, getDisplayUnit } from '@/lib/units';
|
|
import { parseDateForDisplay } from '@/lib/dateUtils';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
interface RideCreditCardProps {
|
|
credit: UserRideCredit;
|
|
position: number;
|
|
maxPosition?: number;
|
|
viewMode: 'grid' | 'list';
|
|
isEditMode?: boolean;
|
|
onUpdate: (creditId: string, updates: Partial<UserRideCredit>) => void;
|
|
onDelete: () => void;
|
|
onReorder?: (creditId: string, newPosition: number) => Promise<void>;
|
|
}
|
|
|
|
export function RideCreditCard({ credit, position, maxPosition, viewMode, isEditMode, onUpdate, onDelete, onReorder }: RideCreditCardProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editCount, setEditCount] = useState(credit.ride_count);
|
|
const [editPosition, setEditPosition] = useState(position);
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [updating, setUpdating] = useState(false);
|
|
|
|
const rideName = credit.rides?.name || 'Unknown Ride';
|
|
const parkName = credit.rides?.parks?.name || 'Unknown Park';
|
|
const parkSlug = credit.rides?.parks?.slug || '';
|
|
const rideSlug = credit.rides?.slug || '';
|
|
const imageUrl = credit.rides?.card_image_url;
|
|
const category = credit.rides?.category || '';
|
|
const location = credit.rides?.parks?.locations;
|
|
const manufacturer = credit.rides?.manufacturer;
|
|
const coasterType = credit.rides?.coaster_type;
|
|
const maxSpeed = credit.rides?.max_speed_kmh;
|
|
const maxHeight = credit.rides?.max_height_meters;
|
|
const inversions = credit.rides?.inversions || 0;
|
|
const intensityLevel = credit.rides?.intensity_level;
|
|
|
|
const handleUpdateCount = async () => {
|
|
try {
|
|
setUpdating(true);
|
|
const { error } = await supabase
|
|
.from('user_ride_credits')
|
|
.update({ ride_count: editCount })
|
|
.eq('id', credit.id);
|
|
|
|
if (error) throw error;
|
|
|
|
toast.success('Ride count updated');
|
|
setIsEditing(false);
|
|
// Optimistic update - pass specific changes
|
|
onUpdate(credit.id, { ride_count: editCount });
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error));
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleQuickIncrement = async () => {
|
|
try {
|
|
const newCount = credit.ride_count + 1;
|
|
|
|
// Optimistic update first
|
|
onUpdate(credit.id, { ride_count: newCount });
|
|
|
|
const { error } = await supabase
|
|
.from('user_ride_credits')
|
|
.update({ ride_count: newCount })
|
|
.eq('id', credit.id);
|
|
|
|
if (error) throw error;
|
|
|
|
toast.success('Ride count increased');
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error));
|
|
// Rollback on error
|
|
onUpdate(credit.id, { ride_count: credit.ride_count });
|
|
}
|
|
};
|
|
|
|
const handlePositionChange = async () => {
|
|
if (editPosition === position || !onReorder || !maxPosition) return;
|
|
|
|
if (editPosition < 1 || editPosition > maxPosition) {
|
|
toast.error(`Position must be between 1 and ${maxPosition}`);
|
|
setEditPosition(position);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await onReorder(credit.id, editPosition);
|
|
toast.success('Position updated');
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error));
|
|
setEditPosition(position);
|
|
}
|
|
};
|
|
|
|
const getCategoryBadge = (category: string) => {
|
|
const categoryMap: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
|
roller_coaster: { label: 'Coaster', variant: 'default' },
|
|
flat_ride: { label: 'Flat Ride', variant: 'secondary' },
|
|
water_ride: { label: 'Water Ride', variant: 'outline' },
|
|
dark_ride: { label: 'Dark Ride', variant: 'outline' },
|
|
};
|
|
const info = categoryMap[category] || { label: category, variant: 'outline' as const };
|
|
return <Badge variant={info.variant}>{info.label}</Badge>;
|
|
};
|
|
|
|
const ridePath = parkSlug && rideSlug
|
|
? `/parks/${parkSlug}/rides/${rideSlug}`
|
|
: '#';
|
|
|
|
if (viewMode === 'list') {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-4">
|
|
{imageUrl && (
|
|
<img
|
|
src={imageUrl}
|
|
alt={rideName}
|
|
className="w-24 h-24 object-cover rounded flex-shrink-0"
|
|
/>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
{/* Header Row */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Link
|
|
to={ridePath}
|
|
className="font-semibold hover:underline truncate"
|
|
>
|
|
{rideName}
|
|
</Link>
|
|
{isEditMode && maxPosition ? (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">#</span>
|
|
<Input
|
|
type="number"
|
|
value={editPosition}
|
|
onChange={(e) => setEditPosition(parseInt(e.target.value) || 1)}
|
|
onBlur={handlePositionChange}
|
|
onKeyDown={(e) => e.key === 'Enter' && handlePositionChange()}
|
|
className="w-14 h-6 text-xs p-1"
|
|
min="1"
|
|
max={maxPosition}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs font-semibold">
|
|
#{position}
|
|
</Badge>
|
|
)}
|
|
{getCategoryBadge(category)}
|
|
</div>
|
|
|
|
<Link
|
|
to={`/parks/${parkSlug}`}
|
|
className="text-sm text-muted-foreground hover:underline block truncate"
|
|
>
|
|
<MapPin className="w-3 h-3 inline mr-1" />
|
|
{parkName}
|
|
{location?.city && `, ${location.city}`}
|
|
{location?.state_province && `, ${location.state_province}`}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Grid - Enhanced */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
|
{manufacturer && (
|
|
<div className="flex items-center gap-1">
|
|
<Factory className="w-3 h-3 text-muted-foreground" />
|
|
<span className="truncate">{manufacturer.name}</span>
|
|
</div>
|
|
)}
|
|
{coasterType && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-muted-foreground">Type:</span>
|
|
<span className="truncate capitalize">{coasterType.replace('_', ' ')}</span>
|
|
</div>
|
|
)}
|
|
{maxSpeed && (
|
|
<div className="flex items-center gap-1">
|
|
<Gauge className="w-3 h-3 text-muted-foreground" />
|
|
<span>{Math.round(maxSpeed)} km/h</span>
|
|
</div>
|
|
)}
|
|
{maxHeight && (
|
|
<div className="flex items-center gap-1">
|
|
<Ruler className="w-3 h-3 text-muted-foreground" />
|
|
<span>{Math.round(maxHeight)} m</span>
|
|
</div>
|
|
)}
|
|
{inversions > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<Zap className="w-3 h-3 text-muted-foreground" />
|
|
<span>{inversions} inversions</span>
|
|
</div>
|
|
)}
|
|
{intensityLevel && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-muted-foreground">Intensity:</span>
|
|
<Badge variant="outline" className="text-xs capitalize">{intensityLevel}</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
{credit.first_ride_date && (
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
{/* ⚠️ Use parseDateForDisplay to prevent timezone shifts */}
|
|
<span>First: {format(parseDateForDisplay(credit.first_ride_date), 'MMM d, yyyy')}</span>
|
|
</div>
|
|
)}
|
|
{credit.last_ride_date && (
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
<span>Last: {format(parseDateForDisplay(credit.last_ride_date), 'MMM d, yyyy')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Personal Rating */}
|
|
{credit.personal_rating && (
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`w-3 h-3 ${
|
|
i < credit.personal_rating!
|
|
? 'fill-yellow-400 text-yellow-400'
|
|
: 'text-muted-foreground/30'
|
|
}`}
|
|
/>
|
|
))}
|
|
<span className="text-xs text-muted-foreground ml-1">Your Rating</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Personal Notes Preview */}
|
|
{credit.personal_notes && (
|
|
<p className="text-xs italic text-muted-foreground line-clamp-2 bg-muted/30 p-2 rounded">
|
|
"{credit.personal_notes}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{isEditing ? (
|
|
<>
|
|
<Input
|
|
type="number"
|
|
value={editCount}
|
|
onChange={(e) => setEditCount(parseInt(e.target.value) || 1)}
|
|
className="w-20"
|
|
min="1"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={handleUpdateCount}
|
|
disabled={updating}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setEditCount(credit.ride_count);
|
|
}}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold">{credit.ride_count}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{credit.ride_count === 1 ? 'ride' : 'rides'}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={handleQuickIncrement}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Ride Credit?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will remove {rideName} from your ride credits. This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Grid view
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
{imageUrl && (
|
|
<div className="aspect-video relative">
|
|
<img
|
|
src={imageUrl}
|
|
alt={rideName}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<CardContent className="p-4">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2 mb-1">
|
|
<Link
|
|
to={ridePath}
|
|
className="font-semibold hover:underline line-clamp-2"
|
|
>
|
|
{rideName}
|
|
</Link>
|
|
<div className="flex gap-1 flex-shrink-0">
|
|
{isEditMode && maxPosition ? (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground">#</span>
|
|
<Input
|
|
type="number"
|
|
value={editPosition}
|
|
onChange={(e) => setEditPosition(parseInt(e.target.value) || 1)}
|
|
onBlur={handlePositionChange}
|
|
onKeyDown={(e) => e.key === 'Enter' && handlePositionChange()}
|
|
className="w-14 h-6 text-xs p-1"
|
|
min="1"
|
|
max={maxPosition}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs font-semibold">
|
|
#{position}
|
|
</Badge>
|
|
)}
|
|
{getCategoryBadge(category)}
|
|
</div>
|
|
</div>
|
|
|
|
<Link
|
|
to={`/parks/${parkSlug}`}
|
|
className="text-sm text-muted-foreground hover:underline block truncate"
|
|
>
|
|
<MapPin className="w-3 h-3 inline mr-1" />
|
|
{parkName}
|
|
</Link>
|
|
</div>
|
|
|
|
{credit.first_ride_date && (
|
|
<div className="text-xs text-muted-foreground">
|
|
<Calendar className="w-3 h-3 inline mr-1" />
|
|
{format(parseDateForDisplay(credit.first_ride_date), 'MMM d, yyyy')}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t">
|
|
{isEditing ? (
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<Input
|
|
type="number"
|
|
value={editCount}
|
|
onChange={(e) => setEditCount(parseInt(e.target.value) || 1)}
|
|
className="w-20"
|
|
min="1"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleUpdateCount}
|
|
disabled={updating}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setEditCount(credit.ride_count);
|
|
}}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="text-sm">
|
|
<span className="font-bold text-lg">{credit.ride_count}</span>
|
|
<span className="text-muted-foreground ml-1">
|
|
{credit.ride_count === 1 ? 'ride' : 'rides'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleQuickIncrement}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Ride Credit?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will remove {rideName} from your ride credits. This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
);
|
|
}
|