diff --git a/src/components/ErrorState.tsx b/src/components/ErrorState.tsx new file mode 100644 index 0000000..cd96d5a --- /dev/null +++ b/src/components/ErrorState.tsx @@ -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 ( + + + +

{title}

+

{description}

+ {onRetry && ( + + )} +
+
+ ); +} + +/** Full-page failure state (with app chrome) for route-level data errors. */ +export function PageError(props: { + title?: string; + description?: string; + onRetry?: () => void; +}) { + return ( + +
+ +
+
+ ); +} diff --git a/src/pages/BountyLeaderboardPage.tsx b/src/pages/BountyLeaderboardPage.tsx index 6738eb4..c5712f9 100644 --- a/src/pages/BountyLeaderboardPage.tsx +++ b/src/pages/BountyLeaderboardPage.tsx @@ -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 ( + +
+ { refetchShops(); refetchSpots(); }} /> +
+
+ ); + } + return (
diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index b6808e0..e1fd88f 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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 ; } - if (error || !profile) { + if (error) { + return refetch()} />; + } + + if (!profile) { return ; } diff --git a/src/pages/CollectionDetailPage.tsx b/src/pages/CollectionDetailPage.tsx index 2f48a5b..5ebc511 100644 --- a/src/pages/CollectionDetailPage.tsx +++ b/src/pages/CollectionDetailPage.tsx @@ -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 ; } - if (error || !collection) { + if (error) { + return refetch()} />; + } + + if (!collection) { return ; } diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 1417a59..0f96699 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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 ; + if (error) return refetch()} />; + + if (!crew) return ; const npub = nip19.npubEncode(crew.pubkey); const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey); diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index 4075160..715155f 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -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() {

Failed to load messages

+
) : conversations && conversations.length > 0 ? ( diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index e06490a..401e753 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -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 ; } - if (error || !event) { + if (error) { + return refetch()} />; + } + + if (!event) { return ; } diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index 3ddae23..1e7a24e 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -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 ; } - if (error || !shop) { + if (error) { + return refetch()} />; + } + + if (!shop) { return ; } diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index ab96d29..a82ee3c 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -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 ; } - if (error || !spot) { + if (error) { + return refetch()} />; + } + + if (!spot) { return ; } diff --git a/src/pages/ZapLeaderboardPage.tsx b/src/pages/ZapLeaderboardPage.tsx index 566d901..a9593e4 100644 --- a/src/pages/ZapLeaderboardPage.tsx +++ b/src/pages/ZapLeaderboardPage.tsx @@ -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() { Top zapped spots - {isLoading ? ( + {isError ? ( + refetch()} /> + ) : isLoading ? ( ) : topTargets.length === 0 ? (

@@ -231,7 +234,9 @@ export default function ZapLeaderboardPage() { Top supporters - {isLoading ? ( + {isError ? ( + refetch()} /> + ) : isLoading ? ( ) : topSenders.length === 0 ? (