- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
540 lines
22 KiB
TypeScript
540 lines
22 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import { Route, Clock, MapPin, Users, Zap, Globe, Calendar, Trophy, Skull, MoreVertical, Flag } from 'lucide-react';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { TrustBadge } from '@/components/TrustBadge';
|
|
import { ProBadge } from '@/components/ProBadge';
|
|
import { ReportDialog } from '@/components/ReportDialog';
|
|
|
|
import type { SkateTour, TourCategory } from '@/lib/types';
|
|
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
|
|
import { TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
interface TourCardProps {
|
|
tour: SkateTour;
|
|
compact?: boolean;
|
|
}
|
|
|
|
function _getCategoryIcon(category: TourCategory) {
|
|
switch (category) {
|
|
case 'local':
|
|
return <Route className="h-4 w-4" />;
|
|
case 'national':
|
|
return <Route className="h-4 w-4" />;
|
|
case 'international':
|
|
return <Globe className="h-4 w-4" />;
|
|
case 'destruction':
|
|
return <Skull className="h-4 w-4" />;
|
|
default:
|
|
return <Route className="h-4 w-4" />;
|
|
}
|
|
}
|
|
|
|
function formatDuration(duration: number | undefined, unit: string | undefined, t: Translate): string {
|
|
if (!duration) return '';
|
|
|
|
switch (unit) {
|
|
case 'hours':
|
|
return t('placesUi.tourCard.durationHours', { count: duration });
|
|
case 'days':
|
|
return t('placesUi.tourCard.durationDays', { count: duration });
|
|
case 'weeks':
|
|
return duration >= 4 ? t('placesUi.tourCard.durationMonths', { count: Math.floor(duration / 4) }) : t('placesUi.tourCard.durationWeeks', { count: duration });
|
|
default:
|
|
return `${duration}`;
|
|
}
|
|
}
|
|
|
|
function formatDateRange(startDate?: number, endDate?: number, withTime = false): string {
|
|
if (!startDate) return '';
|
|
|
|
const start = new Date(startDate * 1000);
|
|
|
|
if (withTime) {
|
|
// Local scheduled session — show the day and start time.
|
|
return start.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
const startStr = start.toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric' });
|
|
|
|
if (!endDate) return startStr;
|
|
|
|
const end = new Date(endDate * 1000);
|
|
const endStr = end.toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
return `${startStr} - ${endStr}`;
|
|
}
|
|
|
|
/**
|
|
* A short, human countdown for an upcoming (or live) tour based on its start
|
|
* date. Returns null when there's no date or the tour is well in the past.
|
|
*/
|
|
function getCountdown(
|
|
tour: SkateTour,
|
|
t: Translate,
|
|
): { label: string; color: string; live?: boolean } | null {
|
|
if (!tour.startDate) return null;
|
|
|
|
const nowSecs = Date.now() / 1000;
|
|
const start = tour.startDate;
|
|
const end = tour.endDate;
|
|
const accent = TOUR_CATEGORIES[tour.category].color;
|
|
|
|
// Currently running (has an end date and we're inside the window).
|
|
if (start <= nowSecs && end && nowSecs <= end) {
|
|
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
|
|
}
|
|
|
|
// Already started but single-session / no end — treat same-day as live, else past.
|
|
if (start <= nowSecs) {
|
|
if (nowSecs - start < 12 * 60 * 60) {
|
|
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const diffSecs = start - nowSecs;
|
|
const days = Math.floor(diffSecs / 86400);
|
|
const hours = Math.floor(diffSecs / 3600);
|
|
|
|
if (hours < 1) return { label: t('placesUi.tourCard.startingSoon'), color: accent, live: true };
|
|
if (hours < 24) return { label: t('placesUi.tourCard.inHours', { count: hours }), color: accent };
|
|
if (days === 1) return { label: t('placesUi.tourCard.tomorrow'), color: accent };
|
|
if (days < 7) return { label: t('placesUi.tourCard.inDays', { count: days }), color: accent };
|
|
if (days < 30) {
|
|
const w = Math.round(days / 7);
|
|
return { label: t('placesUi.tourCard.inWeeks', { count: w }), color: accent };
|
|
}
|
|
const m = Math.round(days / 30);
|
|
return { label: t('placesUi.tourCard.inMonths', { count: m }), color: accent };
|
|
}
|
|
|
|
function getTourStatus(tour: SkateTour, t: Translate): { label: string; color: string } | null {
|
|
if (tour.category !== 'destruction') return null;
|
|
|
|
const now = Date.now() / 1000;
|
|
|
|
if (tour.startDate && now < tour.startDate) {
|
|
return { label: t('placesUi.tourCard.registrationOpen'), color: '#ccff00' };
|
|
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
|
|
return { label: t('placesUi.tourCard.active'), color: '#ff0040' };
|
|
} else if (tour.endDate && now > tour.endDate) {
|
|
return { label: t('placesUi.tourCard.completed'), color: '#888' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function TourCard({ tour, compact = false }: TourCardProps) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(tour.pubkey);
|
|
const { user } = useCurrentUser();
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(tour.pubkey);
|
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
|
|
|
const categoryInfo = TOUR_CATEGORIES[tour.category];
|
|
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
|
|
const tourStatus = getTourStatus(tour, t);
|
|
const countdown = getCountdown(tour, t);
|
|
const isOwner = user?.pubkey === tour.pubkey;
|
|
|
|
// Get location text based on category
|
|
const locationText = tour.category === 'destruction' || tour.category === 'international'
|
|
? tour.countries?.join(', ') || tour.cities?.join(', ') || t('placesUi.tourCard.worldwide')
|
|
: tour.location || tour.cities?.join(', ');
|
|
|
|
if (compact) {
|
|
return (
|
|
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
|
|
<Card
|
|
className="group hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur"
|
|
style={{
|
|
borderColor: `${categoryInfo.color}33`,
|
|
boxShadow: `0 0 0 0 ${categoryInfo.color}00`
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = `${categoryInfo.color}99`;
|
|
e.currentTarget.style.boxShadow = `0 10px 40px ${categoryInfo.color}33`;
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
|
|
e.currentTarget.style.boxShadow = `0 0 0 0 ${categoryInfo.color}00`;
|
|
}}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-4">
|
|
{/* Category Badge */}
|
|
<div
|
|
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center"
|
|
style={{ backgroundColor: `${categoryInfo.color}20`, border: `2px solid ${categoryInfo.color}80` }}
|
|
>
|
|
<span className="text-xl">{categoryInfo.emoji}</span>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2 mb-1">
|
|
<h3
|
|
className="font-bold text-white transition-colors line-clamp-1 uppercase tracking-wide"
|
|
style={{ color: 'white' }}
|
|
>
|
|
{tour.title}
|
|
</h3>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<ProBadge featuredUntil={tour.featuredUntil} compact />
|
|
{countdown && (
|
|
<Badge
|
|
className={cn(
|
|
'text-[10px] border-0 font-bold uppercase',
|
|
countdown.live && 'animate-pulse'
|
|
)}
|
|
style={{ backgroundColor: countdown.color, color: countdown.color === '#ccff00' ? 'black' : 'white' }}
|
|
>
|
|
{countdown.label}
|
|
</Badge>
|
|
)}
|
|
{tourStatus && (
|
|
<Badge
|
|
className="text-xs border-0 font-bold uppercase"
|
|
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
|
|
>
|
|
{tourStatus.label}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0 font-bold"
|
|
style={{ borderColor: categoryInfo.color, color: categoryInfo.color }}
|
|
>
|
|
{TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
|
|
</Badge>
|
|
{tour.duration && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" style={{ color: categoryInfo.color }} />
|
|
{formatDuration(tour.duration, tour.durationUnit, t)}
|
|
</span>
|
|
)}
|
|
{locationText && (
|
|
<span className="flex items-center gap-1 truncate">
|
|
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
|
|
<span className="truncate">{locationText}</span>
|
|
</span>
|
|
)}
|
|
<TrustBadge pubkey={tour.pubkey} size="sm" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
|
|
<Card
|
|
className="group overflow-hidden transition-all duration-300 border-2 bg-black/90 backdrop-blur"
|
|
style={{
|
|
borderColor: `${categoryInfo.color}33`,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = `${categoryInfo.color}80`;
|
|
e.currentTarget.style.boxShadow = `0 20px 50px ${categoryInfo.color}20`;
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}
|
|
>
|
|
{/* Image */}
|
|
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
|
|
{tour.image ? (
|
|
<img
|
|
src={tour.image}
|
|
alt={tour.title}
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-full h-full flex items-center justify-center"
|
|
style={{ background: `linear-gradient(135deg, ${categoryInfo.color}20 0%, ${categoryInfo.color}05 100%)` }}
|
|
>
|
|
{tour.category === 'destruction' ? (
|
|
<Skull className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
|
|
) : (
|
|
<Route className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Category overlay */}
|
|
<div className="absolute top-3 left-3">
|
|
<Badge
|
|
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
|
|
style={{
|
|
backgroundColor: `${categoryInfo.color}dd`,
|
|
color: (categoryInfo.color as string) === '#ccff00' ? 'black' : 'white'
|
|
}}
|
|
>
|
|
{categoryInfo.emoji} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : categoryInfo.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Status/Price overlay */}
|
|
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
|
|
<ProBadge featuredUntil={tour.featuredUntil} />
|
|
{countdown && (
|
|
<Badge
|
|
className={cn(
|
|
'backdrop-blur-sm shadow-lg font-bold text-xs uppercase',
|
|
countdown.live && 'animate-pulse'
|
|
)}
|
|
style={{
|
|
backgroundColor: `${countdown.color}ee`,
|
|
color: countdown.color === '#ccff00' ? 'black' : 'white',
|
|
}}
|
|
>
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
{countdown.label}
|
|
</Badge>
|
|
)}
|
|
{tourStatus && (
|
|
<Badge
|
|
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
|
|
style={{
|
|
backgroundColor: `${tourStatus.color}dd`,
|
|
color: tourStatus.color === '#ccff00' ? 'black' : 'white'
|
|
}}
|
|
>
|
|
{tourStatus.label}
|
|
</Badge>
|
|
)}
|
|
{tour.prizePool && (
|
|
<Badge className="bg-orange-500/90 backdrop-blur-sm shadow-lg font-bold text-xs">
|
|
<Trophy className="h-3 w-3 mr-1" />
|
|
{t('placesUi.tourCard.prizePool', { amount: tour.prizePool.toLocaleString() })}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<CardHeader className="pb-2">
|
|
{/* Guide/Organizer info */}
|
|
<div className="mb-3 pb-2 border-b" style={{ borderColor: `${categoryInfo.color}33` }}>
|
|
<div className="flex items-center gap-2">
|
|
<Avatar className="h-8 w-8" style={{ border: `1px solid ${categoryInfo.color}50` }}>
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="text-xs" style={{ backgroundColor: `${categoryInfo.color}20`, color: categoryInfo.color }}>
|
|
{displayName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h4 className="font-bold text-white text-sm truncate">{displayName}</h4>
|
|
<TrustBadge pubkey={tour.pubkey} size="sm" />
|
|
</div>
|
|
{metadata?.nip05 && (
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
|
|
<span className="text-[#ccff00] font-bold truncate">{metadata.nip05}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3
|
|
className="font-bold text-lg leading-tight text-white transition-colors line-clamp-2 uppercase"
|
|
>
|
|
{tour.title}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Tour schedule — shown whenever the tour has a date set */}
|
|
{tour.startDate && (
|
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
<Calendar className="h-4 w-4" style={{ color: categoryInfo.color }} />
|
|
<span>{formatDateRange(tour.startDate, tour.endDate, tour.category === 'local')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tour meta info */}
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
|
|
{tour.duration && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-4 w-4" style={{ color: categoryInfo.color }} />
|
|
{formatDuration(tour.duration, tour.durationUnit, t)}
|
|
</span>
|
|
)}
|
|
{tour.maxGroupSize && (
|
|
<span className="flex items-center gap-1">
|
|
<Users className="h-4 w-4 text-[#ff6600]" />
|
|
{t('placesUi.tourCard.maxGroup', { count: tour.maxGroupSize })}
|
|
</span>
|
|
)}
|
|
{tour.maxTeams && (
|
|
<span className="flex items-center gap-1">
|
|
<Users className="h-4 w-4 text-[#ff6600]" />
|
|
{t('placesUi.tourCard.teamsMax', { count: tour.maxTeams })}
|
|
</span>
|
|
)}
|
|
{skillInfo && (
|
|
<span className="flex items-center gap-1">
|
|
<span>{skillInfo.emoji}</span>
|
|
{skillInfo.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{locationText && (
|
|
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
|
|
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
|
|
{locationText}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="pb-4">
|
|
{tour.description && (
|
|
<p className="text-sm text-gray-400 line-clamp-2">
|
|
{tour.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Price or Entry Fee */}
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t" style={{ borderColor: `${categoryInfo.color}33` }}>
|
|
<div className="flex items-center gap-2">
|
|
{tour.category === 'destruction' && tour.entryFee ? (
|
|
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
|
|
<Zap className="h-3 w-3 mr-1" />
|
|
{t('placesUi.tourCard.entry', { amount: tour.entryFee.toLocaleString() })}
|
|
</Badge>
|
|
) : tour.price ? (
|
|
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
|
|
<Zap className="h-3 w-3 mr-1" />
|
|
{t('placesUi.tourCard.price', { amount: tour.price.toLocaleString() })}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="border-green-500/50 text-green-400">
|
|
{t('placesUi.tourCard.contactForPrice')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
|
<ZapButton target={tour.event} className="text-gray-400 hover:text-[#ccff00]" />
|
|
{/* More options menu */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8 text-gray-400 hover:text-white"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
{!isOwner && (
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowReportDialog(true);
|
|
}}
|
|
className="text-red-500 focus:text-red-500"
|
|
>
|
|
<Flag className="h-4 w-4 mr-2" />
|
|
{t('placesUi.eventCard.report')}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Report Dialog */}
|
|
<ReportDialog
|
|
open={showReportDialog}
|
|
onOpenChange={setShowReportDialog}
|
|
targetType="tour"
|
|
targetPubkey={tour.pubkey}
|
|
targetEventId={tour.id}
|
|
targetTitle={tour.title}
|
|
/>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function TourCardSkeleton({ compact = false }: { compact?: boolean }) {
|
|
if (compact) {
|
|
return (
|
|
<Card className="border-2 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-4">
|
|
<Skeleton className="w-14 h-14 rounded-lg" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-5 w-3/4" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="overflow-hidden border-2 border-gray-800">
|
|
<Skeleton className="aspect-[16/9] w-full" />
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-800">
|
|
<Skeleton className="w-8 h-8 rounded-full" />
|
|
<div className="flex-1 space-y-1">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-3 w-32" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
</CardHeader>
|
|
<CardContent className="pb-4">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-4/5" />
|
|
</div>
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
|
|
<Skeleton className="h-6 w-24" />
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|