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 { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { ErrorState } from '@/components/ErrorState';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -138,8 +139,8 @@ export default function BountyLeaderboardPage() {
});
const { user } = useCurrentUser();
const { data: shops, isLoading: shopsLoading } = useSkateShops(500);
const { data: spots, isLoading: spotsLoading } = useSkateSpots(500);
const { data: shops, isLoading: shopsLoading, isError: shopsError, refetch: refetchShops } = useSkateShops(500);
const { data: spots, isLoading: spotsLoading, isError: spotsError, refetch: refetchSpots } = useSkateSpots(500);
// Calculate shop leaderboard
const shopLeaderboard = useMemo(() => {
@ -202,6 +203,16 @@ export default function BountyLeaderboardPage() {
const totalSpots = spots?.length || 0;
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 (
<Layout>
<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 { useParams, Link, useNavigate } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -82,7 +83,7 @@ function CoachDetailSkeleton() {
export default function CoachDetailPage() {
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 { user } = useCurrentUser();
const navigate = useNavigate();
@ -116,7 +117,11 @@ export default function CoachDetailPage() {
return <CoachDetailSkeleton />;
}
if (error || !profile) {
if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!profile) {
return <NotFound />;
}

View file

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

View file

@ -3,6 +3,7 @@ import { useSeoMeta } from '@unhead/react';
import { useParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -138,7 +139,7 @@ function FeedItem({
export default function CrewDetailPage() {
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 { user } = useCurrentUser();
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 founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);

View file

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

View file

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

View file

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

View file

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

View file

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