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:
parent
cbd12f3e52
commit
8e35859188
10 changed files with 120 additions and 18 deletions
55
src/components/ErrorState.tsx
Normal file
55
src/components/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue