Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions nevo_frontend/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ApiError } from '@/lib/api-client';
import { parseApiError } from '@/lib/errors';

describe('parseApiError', () => {
it('extracts message from axios response.data.message', () => {
const err = {
isAxiosError: true,
response: { data: { message: 'Invalid credentials' } },
message: 'Request failed with status code 401',
};

expect(parseApiError(err)).toBe('Invalid credentials');
});

it('extracts message from axios response.data.error', () => {
const err = {
isAxiosError: true,
response: { data: { error: 'Pool not found' } },
message: 'Request failed with status code 404',
};

expect(parseApiError(err)).toBe('Pool not found');
});

it('falls back to err.message for axios errors without response payload', () => {
const err = {
isAxiosError: true,
message: 'Network Error',
};

expect(parseApiError(err)).toBe('Network Error');
});

it('extracts message from ApiError data.message', () => {
const err = new ApiError(400, 'Bad Request', {
message: 'Title is required',
});

expect(parseApiError(err)).toBe('Title is required');
});

it('extracts message from ApiError data.error', () => {
const err = new ApiError(500, 'Internal Server Error', {
error: 'Database unavailable',
});

expect(parseApiError(err)).toBe('Database unavailable');
});

it('falls back to ApiError.message when data has no message fields', () => {
const err = new ApiError(403, 'Forbidden');

expect(parseApiError(err)).toBe('Forbidden');
});

it('returns fallback message for unknown errors', () => {
expect(parseApiError(null)).toBe('Something went wrong. Please try again.');
expect(parseApiError(undefined)).toBe(
'Something went wrong. Please try again.'
);
expect(parseApiError({})).toBe('Something went wrong. Please try again.');
});
});
38 changes: 26 additions & 12 deletions nevo_frontend/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useWalletStore } from '@/src/store/walletStore';
import { apiClient } from '@/lib/api-client';
import { connect, signWithWallet, getPublicKey } from '@/app/stellar-wallets-kit';
import { parseApiError } from '@/lib/errors';
import {
connect,
signWithWallet,
getPublicKey,
} from '@/app/stellar-wallets-kit';
import { Spinner } from '@/components/Spinner';

function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, loading, initialize, setAccessToken } = useWalletStore();
const { isAuthenticated, loading, initialize, setAccessToken } =
useWalletStore();
const [accepted, setAccepted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -39,25 +45,31 @@ function LoginPageContent() {
}

// Get challenge nonce
const challenge = await apiClient.get<{ nonce: string }>('/auth/challenge', {
params: { publicKey },
});
const challenge = await apiClient.get<{ nonce: string }>(
'/auth/challenge',
{
params: { publicKey },
}
);

// Sign the nonce
const signature = await signWithWallet(challenge.nonce);

// Verify and get access token
const authResult = await apiClient.post<{ accessToken: string }>('/auth/verify', {
publicKey,
signature,
message: challenge.nonce,
});
const authResult = await apiClient.post<{ accessToken: string }>(
'/auth/verify',
{
publicKey,
signature,
message: challenge.nonce,
}
);

// Set the access token in the store
setAccessToken(authResult.accessToken);
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setError(parseApiError(err));
} finally {
setAuthLoading(false);
}
Expand Down Expand Up @@ -110,7 +122,9 @@ function LoginPageContent() {
disabled={authLoading}
className="w-full rounded-full bg-brand-600 px-6 py-3 text-sm font-medium text-white hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{authLoading ? 'Authenticating...' : 'Connect Wallet & Sign In'}
{authLoading
? 'Authenticating...'
: 'Connect Wallet & Sign In'}
</button>
{error && (
<p className="text-sm text-red-500" role="alert">
Expand Down
5 changes: 2 additions & 3 deletions nevo_frontend/app/pools/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,8 @@ export default function PoolDetailPage() {

await fetchPool(Number(pool.id));
} catch (err: unknown) {
const error = err as Error;
toast(error.message || 'Failed to close pool', 'error');
console.error(error);
toast(parseApiError(err), 'error');
console.error(err);
} finally {
setIsClosing(false);
}
Expand Down
3 changes: 1 addition & 2 deletions nevo_frontend/app/pools/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,7 @@ function CreatePoolPageContent() {

setSubmitted(true);
} catch (error) {
const err = error as Error;
setErrors({ submit: err?.message || 'Failed to submit transaction.' });
setErrors({ submit: parseApiError(error) });
} finally {
setSubmitting(false);
setSubmitStep('idle');
Expand Down
5 changes: 2 additions & 3 deletions nevo_frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ApiProfile,
type ApiDonation,
} from '@/lib/api-client';
import { parseApiError } from '@/lib/errors';
import { toast } from '@/components/Toast';

interface UserPreferences {
Expand Down Expand Up @@ -97,9 +98,7 @@ export default function ProfilePage() {
toast('Profile updated successfully');
setIsEditingProfile(false);
} catch (err) {
const msg =
err instanceof Error ? err.message : 'Failed to update profile';
toast(msg, 'error');
toast(parseApiError(err), 'error');
}
};

Expand Down
3 changes: 2 additions & 1 deletion nevo_frontend/components/DonateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Server } from '@stellar/stellar-sdk/rpc';
import { useWalletStore } from '@/src/store/walletStore';
import { useDonationsStore } from '@/src/store/donationsStore';
import { contractService } from '@/lib/contract-service';
import { parseApiError } from '@/lib/errors';
import type { Pool } from '@/src/store/poolsStore';
import { WalletAddress } from './WalletAddress';

Expand Down Expand Up @@ -139,7 +140,7 @@ export function DonateModal({
onDonationSuccess?.();
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : 'Transaction failed.';
const msg = parseApiError(err);
if (
msg.toLowerCase().includes('cancel') ||
msg.toLowerCase().includes('reject') ||
Expand Down
2 changes: 1 addition & 1 deletion nevo_frontend/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class ApiClient {

constructor(
baseURL: string = '',
defaultTimeout: number = 15000,
defaultTimeout: number = 10_000,
rateLimit: Partial<RateLimitOptions> = DEFAULT_RATE_LIMIT_OPTIONS
) {
this.baseURL = baseURL || env.NEXT_PUBLIC_API_BASE_URL;
Expand Down
80 changes: 80 additions & 0 deletions nevo_frontend/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ApiError } from './api-client';

const FALLBACK_MESSAGE = 'Something went wrong. Please try again.';

interface AxiosErrorResponseData {
message?: unknown;
error?: unknown;
}

interface AxiosLikeError {
isAxiosError?: boolean;
response?: {
data?: AxiosErrorResponseData;
};
message?: unknown;
}

function isAxiosError(err: unknown): err is AxiosLikeError {
if (typeof err !== 'object' || err === null) {
return false;
}

const candidate = err as AxiosLikeError;
if (candidate.isAxiosError === true) {
return true;
}

return (
'response' in candidate &&
typeof candidate.response === 'object' &&
candidate.response !== null
);
}

function extractString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function extractFromPayload(payload: unknown): string | undefined {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}

const record = payload as AxiosErrorResponseData;
return extractString(record.message) ?? extractString(record.error);
}

export function parseApiError(err: unknown): string {
if (isAxiosError(err)) {
const fromResponse = extractFromPayload(err.response?.data);
if (fromResponse) {
return fromResponse;
}
}

if (err instanceof ApiError) {
const fromData = extractFromPayload(err.data);
if (fromData) {
return fromData;
}
}

if (err instanceof Error && err.message) {
return err.message;
}

if (
typeof err === 'object' &&
err !== null &&
'message' in err &&
typeof (err as { message: unknown }).message === 'string'
) {
const message = (err as { message: string }).message;
if (message.length > 0) {
return message;
}
}

return FALLBACK_MESSAGE;
}
23 changes: 13 additions & 10 deletions nevo_frontend/src/store/poolsStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { apiClient } from '@/lib/api-client';
import { parseApiError } from '@/lib/errors';

export type PoolStatus = 'Active' | 'Completed';
export type SortOption = 'newest' | 'most_raised' | 'goal_low';
Expand Down Expand Up @@ -80,25 +81,28 @@ export const usePoolsStore = create<PoolsState>()(
// Merge provided filters with current store filters
const currentFilters = get().filters;
const mergedFilters = { ...currentFilters, ...filters };

// Build params object
const params: Record<string, unknown> = {};
if (mergedFilters.search) params.search = mergedFilters.search;
if (mergedFilters.categories.length > 0) params.categories = mergedFilters.categories;
if (mergedFilters.statuses.length > 0) params.statuses = mergedFilters.statuses;
if (mergedFilters.minTarget != null) params.minTarget = mergedFilters.minTarget;
if (mergedFilters.maxTarget != null) params.maxTarget = mergedFilters.maxTarget;
if (mergedFilters.categories.length > 0)
params.categories = mergedFilters.categories;
if (mergedFilters.statuses.length > 0)
params.statuses = mergedFilters.statuses;
if (mergedFilters.minTarget != null)
params.minTarget = mergedFilters.minTarget;
if (mergedFilters.maxTarget != null)
params.maxTarget = mergedFilters.maxTarget;
if (mergedFilters.dateFrom) params.dateFrom = mergedFilters.dateFrom;
if (mergedFilters.dateTo) params.dateTo = mergedFilters.dateTo;
if (get().sortBy) params.sortBy = get().sortBy;

const data = await apiClient.get<Pool[]>('/pools', { params });
get().setPools(data);
set({ loading: false });
} catch (error) {
const err = error as Error;
set({
error: err?.message || 'Failed to fetch pools',
error: parseApiError(error) || 'Failed to fetch pools',
loading: false,
});
}
Expand All @@ -111,9 +115,8 @@ export const usePoolsStore = create<PoolsState>()(
set({ currentPool: data, poolLoading: false });
return data;
} catch (error) {
const err = error as Error;
set({
error: err?.message || 'Failed to fetch pool',
error: parseApiError(error) || 'Failed to fetch pool',
poolLoading: false,
});
return null;
Expand Down
Loading