fix(ux): Batch B — errors no longer masquerade as empty/404

Adds a shared ErrorState/PageError with a retry action. Detail pages (spot,
shop, collection, coach, crew, event) now show an honest failure + Try again
instead of '404 Not Found' — a relay hiccup previously implied the content was
deleted. The zap/bounty leaderboards surface errors instead of 'no content yet',
and the DM inbox gains a retry button.
This commit is contained in:
SkateSpots 2026-09-26 17:51:30 +07:00
parent cbd12f3e52
commit 8e35859188
10 changed files with 120 additions and 18 deletions

View file

@ -0,0 +1,55 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Layout } from '@/components/Layout';
import { cn } from '@/lib/utils';
/**
* Shared failure state for data-driven pages.
*
* Previously these pages rendered "404 Not Found" (or an empty state) when a
* relay query failed, which told users the content had been deleted — false and
* unactionable. This keeps the failure honest and gives a retry path.
*/
export function ErrorState({
title = 'Couldn’t load this',
description = 'The network request failed. Check your connection and try again.',
onRetry,
className,
}: {
title?: string;
description?: string;
onRetry?: () => void;
className?: string;
}) {
return (
<Card className={cn('border-2 border-dashed border-red-500/30 bg-black/50', className)}>
<CardContent className="py-12 text-center">
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-red-500/60" />
<p className="mb-1 font-bold uppercase tracking-wide text-white">{title}</p>
<p className="mx-auto mb-5 max-w-sm text-sm text-gray-400">{description}</p>
{onRetry && (
<Button onClick={onRetry} className="gap-2 bg-white/10 font-bold uppercase hover:bg-white/20">
<RefreshCw className="h-4 w-4" />
Try again
</Button>
)}
</CardContent>
</Card>
);
}
/** Full-page failure state (with app chrome) for route-level data errors. */
export function PageError(props: {
title?: string;
description?: string;
onRetry?: () => void;
}) {
return (
<Layout>
<div className="container mx-auto max-w-4xl px-4 py-8">
<ErrorState {...props} />
</div>
</Layout>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { ErrorState } from '@/components/ErrorState';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -138,8 +139,8 @@ export default function BountyLeaderboardPage() {
}); });
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: shops, isLoading: shopsLoading } = useSkateShops(500); const { data: shops, isLoading: shopsLoading, isError: shopsError, refetch: refetchShops } = useSkateShops(500);
const { data: spots, isLoading: spotsLoading } = useSkateSpots(500); const { data: spots, isLoading: spotsLoading, isError: spotsError, refetch: refetchSpots } = useSkateSpots(500);
// Calculate shop leaderboard // Calculate shop leaderboard
const shopLeaderboard = useMemo(() => { const shopLeaderboard = useMemo(() => {
@ -202,6 +203,16 @@ export default function BountyLeaderboardPage() {
const totalSpots = spots?.length || 0; const totalSpots = spots?.length || 0;
const totalMappers = combinedLeaderboard.length; const totalMappers = combinedLeaderboard.length;
if (shopsError || spotsError) {
return (
<Layout>
<div className="container mx-auto max-w-3xl px-4 py-8">
<ErrorState onRetry={() => { refetchShops(); refetchSpots(); }} />
</div>
</Layout>
);
}
return ( return (
<Layout> <Layout>
<div className="container px-4 py-6 sm:py-8 max-w-4xl"> <div className="container px-4 py-6 sm:py-8 max-w-4xl">

View file

@ -1,6 +1,7 @@
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -82,7 +83,7 @@ function CoachDetailSkeleton() {
export default function CoachDetailPage() { export default function CoachDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey); const { data: profile, isLoading, error, refetch } = useLabProfile(identifier || '', pubkey);
const author = useAuthor(profile?.pubkey); const author = useAuthor(profile?.pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
@ -116,7 +117,11 @@ export default function CoachDetailPage() {
return <CoachDetailSkeleton />; return <CoachDetailSkeleton />;
} }
if (error || !profile) { if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!profile) {
return <NotFound />; return <NotFound />;
} }

View file

@ -4,6 +4,7 @@ import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -64,7 +65,7 @@ function CollectionDetailSkeleton() {
export default function CollectionDetailPage() { export default function CollectionDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: collection, isLoading, error } = useSpotCollection(identifier || '', pubkey); const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { nostr } = useNostr(); const { nostr } = useNostr();
const { config } = useAppContext(); const { config } = useAppContext();
@ -221,7 +222,11 @@ export default function CollectionDetailPage() {
return <CollectionDetailSkeleton />; return <CollectionDetailSkeleton />;
} }
if (error || !collection) { if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!collection) {
return <NotFound />; return <NotFound />;
} }

View file

@ -3,6 +3,7 @@ import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -138,7 +139,7 @@ function FeedItem({
export default function CrewDetailPage() { export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey); const { data: crew, isLoading, error, refetch } = useCrew(identifier || '', pubkey);
const author = useAuthor(crew?.pubkey); const author = useAuthor(crew?.pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew); const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
@ -242,7 +243,9 @@ export default function CrewDetailPage() {
); );
} }
if (error || !crew) return <NotFound />; if (error) return <PageError onRetry={() => refetch()} />;
if (!crew) return <NotFound />;
const npub = nip19.npubEncode(crew.pubkey); const npub = nip19.npubEncode(crew.pubkey);
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey); const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);

View file

@ -151,7 +151,7 @@ function ConversationSkeleton() {
export default function DirectMessagesPage() { export default function DirectMessagesPage() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
const { data: conversations, isLoading, error } = useConversations(); const { data: conversations, isLoading, error, refetch } = useConversations();
const { getLastRead } = useDMRead(); const { getLastRead } = useDMRead();
const [showNewMessage, setShowNewMessage] = useState(false); const [showNewMessage, setShowNewMessage] = useState(false);
@ -284,6 +284,9 @@ export default function DirectMessagesPage() {
<CardContent className="py-8 text-center"> <CardContent className="py-8 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-3 text-red-500" /> <AlertCircle className="h-12 w-12 mx-auto mb-3 text-red-500" />
<p className="text-gray-400">Failed to load messages</p> <p className="text-gray-400">Failed to load messages</p>
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
Try again
</Button>
</CardContent> </CardContent>
</Card> </Card>
) : conversations && conversations.length > 0 ? ( ) : conversations && conversations.length > 0 ? (

View file

@ -3,6 +3,7 @@ import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { RouteDialog } from '@/components/RouteDialog'; import { RouteDialog } from '@/components/RouteDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -82,7 +83,7 @@ function EventDetailSkeleton() {
export default function EventDetailPage() { export default function EventDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey); const { data: event, isLoading, error, refetch } = useSkateEvent(identifier || '', pubkey);
const author = useAuthor(event?.pubkey); const author = useAuthor(event?.pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
@ -269,7 +270,11 @@ export default function EventDetailPage() {
return <EventDetailSkeleton />; return <EventDetailSkeleton />;
} }
if (error || !event) { if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!event) {
return <NotFound />; return <NotFound />;
} }

View file

@ -4,6 +4,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { RouteDialog } from '@/components/RouteDialog'; import { RouteDialog } from '@/components/RouteDialog';
@ -79,7 +80,7 @@ function ShopDetailSkeleton() {
export default function ShopDetailPage() { export default function ShopDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey); const { data: shop, isLoading, error, refetch } = useShop(identifier || '', pubkey);
const author = useAuthor(shop?.pubkey); const author = useAuthor(shop?.pubkey);
const { user, metadata: userMetadata } = useCurrentUser(); const { user, metadata: userMetadata } = useCurrentUser();
const { mutateAsync: deleteEntity } = useDeleteEntity(); const { mutateAsync: deleteEntity } = useDeleteEntity();
@ -185,7 +186,11 @@ export default function ShopDetailPage() {
return <ShopDetailSkeleton />; return <ShopDetailSkeleton />;
} }
if (error || !shop) { if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!shop) {
return <NotFound />; return <NotFound />;
} }

View file

@ -6,6 +6,7 @@ import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox'; import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { PageError } from '@/components/ErrorState';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -121,7 +122,7 @@ function SpotDetailSkeleton() {
export default function SpotDetailPage() { export default function SpotDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: spot, isLoading, error } = useSkateSpot(identifier || '', pubkey); const { data: spot, isLoading, error, refetch } = useSkateSpot(identifier || '', pubkey);
const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || ''); const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || '');
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier); const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
const author = useAuthor(spot?.pubkey); const author = useAuthor(spot?.pubkey);
@ -237,7 +238,11 @@ export default function SpotDetailPage() {
return <SpotDetailSkeleton />; return <SpotDetailSkeleton />;
} }
if (error || !spot) { if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!spot) {
return <NotFound />; return <NotFound />;
} }

View file

@ -6,6 +6,7 @@ import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ErrorState } from '@/components/ErrorState';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard'; import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
@ -156,7 +157,7 @@ export default function ZapLeaderboardPage() {
return seconds ? Math.floor(Date.now() / 1000) - seconds : undefined; return seconds ? Math.floor(Date.now() / 1000) - seconds : undefined;
}, [range]); }, [range]);
const { data, isLoading } = useZapLeaderboard(since); const { data, isLoading, isError, refetch } = useZapLeaderboard(since);
const { data: spots } = useSkateSpots(200); const { data: spots } = useSkateSpots(200);
const spotByCoord = useMemo(() => { const spotByCoord = useMemo(() => {
@ -208,7 +209,9 @@ export default function ZapLeaderboardPage() {
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
Top zapped spots Top zapped spots
</h2> </h2>
{isLoading ? ( {isError ? (
<ErrorState onRetry={() => refetch()} />
) : isLoading ? (
<RowsSkeleton /> <RowsSkeleton />
) : topTargets.length === 0 ? ( ) : topTargets.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500"> <p className="py-8 text-center text-sm text-gray-500">
@ -231,7 +234,9 @@ export default function ZapLeaderboardPage() {
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Top supporters Top supporters
</h2> </h2>
{isLoading ? ( {isError ? (
<ErrorState onRetry={() => refetch()} />
) : isLoading ? (
<RowsSkeleton /> <RowsSkeleton />
) : topSenders.length === 0 ? ( ) : topSenders.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500"> <p className="py-8 text-center text-sm text-gray-500">