feat(web): add QueryErrorBoundary and use real map coordinates

Add global QueryErrorResetBoundary wrapping the app so TanStack Query
errors are caught with a retry UI instead of crashing. Enable
throwOnError in QueryClient defaults. Update ListingMap to use real
latitude/longitude from API when available, falling back to city-based
jitter for listings without coordinates.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 17:58:35 +07:00
parent e03c4699d0
commit ab478a565a
4 changed files with 77 additions and 2 deletions

View File

@@ -38,6 +38,9 @@ const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat]
const DEFAULT_ZOOM = 12;
function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } {
if (listing.property.latitude != null && listing.property.longitude != null) {
return { lat: listing.property.latitude, lng: listing.property.longitude };
}
const base = CITY_COORDS[listing.property.city] || [10.8231, 106.6297];
const seed = listing.id.charCodeAt(0) + index;
return {

View File

@@ -1,9 +1,78 @@
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider, QueryErrorResetBoundary } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { getQueryClient } from '@/lib/query-client';
interface FallbackProps {
error: Error;
reset: () => void;
}
function QueryErrorFallback({ error, reset }: FallbackProps) {
const t = useTranslations();
return (
<div className="flex min-h-[200px] flex-col items-center justify-center px-4" role="alert">
<p className="text-sm text-destructive">{t('error.description')}</p>
{error.message && (
<p className="mt-1 text-xs text-muted-foreground">{error.message}</p>
)}
<button
onClick={reset}
className="mt-3 inline-flex h-8 items-center rounded-md bg-primary px-4 text-xs font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('common.retry')}
</button>
</div>
);
}
interface ErrorBoundaryState {
error: Error | null;
}
class QueryErrorBoundaryInner extends Component<
{ children: ReactNode; onReset: () => void },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
if (process.env.NODE_ENV !== 'production') {
console.error('Query error boundary caught:', error, info);
}
}
reset = () => {
this.props.onReset();
this.setState({ error: null });
};
render() {
if (this.state.error) {
return <QueryErrorFallback error={this.state.error} reset={this.reset} />;
}
return this.props.children;
}
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<QueryErrorResetBoundary>
{({ reset }) => (
<QueryErrorBoundaryInner onReset={reset}>
{children}
</QueryErrorBoundaryInner>
)}
</QueryErrorResetBoundary>
</QueryClientProvider>
);
}

View File

@@ -64,6 +64,8 @@ export interface ListingDetail {
legalStatus: string | null;
amenities: string[] | null;
projectName: string | null;
latitude: number | null;
longitude: number | null;
media: PropertyMedia[];
};
seller: {

View File

@@ -11,6 +11,7 @@ function makeQueryClient() {
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
throwOnError: true,
},
mutations: {
retry: 1,