Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8f07773
fix: deduplicate event subscriptions across hooks (#833)
Peolite001 Jul 30, 2026
dcfc191
refactor: unify error types for on-chain and off-chain clients (#837)
Peolite001 Jul 30, 2026
883b7d1
fix: resolve missing properties in WalletContextType
Peolite001 Jul 31, 2026
163a6b8
fix: resolve missing properties in WalletContextType
Peolite001 Jul 31, 2026
e423c6d
Merge branch 'main' into fix/duplicate-event-subscriptions
Peolite001 Jul 31, 2026
65e2bfa
fix: resolve StellarSdk rpc Server crash during tests
Peolite001 Jul 31, 2026
49b1957
fix: resolve build and typecheck errors from merge conflicts
Peolite001 Jul 31, 2026
8fa9e7a
fix(test): resolve duplicate element and transform errors
Peolite001 Jul 31, 2026
b912754
test: mock contractClient queries and fix act warnings in tests
Peolite001 Jul 31, 2026
7cf0d2e
style: remove unused imports to fix eslint errors
Peolite001 Jul 31, 2026
912f511
style: fix formatting issues using Prettier
Peolite001 Jul 31, 2026
f187e42
test(e2e): fix locators and race conditions, add missing translations
Peolite001 Jul 31, 2026
4e8b5fa
style: format e2e test files
Peolite001 Jul 31, 2026
075f9eb
Merge branch 'main' into fix/unify-client-errors
Peolite001 Jul 31, 2026
63f5294
Merge branch 'fix/duplicate-event-subscriptions' into fix/unify-clien…
Peolite001 Jul 31, 2026
22fbef4
Merge branch 'fix/unify-client-errors' of https://github.com/Peolite0…
Peolite001 Jul 31, 2026
d9f1d6b
Merge branch 'main' into fix/unify-client-errors
Peolite001 Aug 1, 2026
ab66c0d
style: format newly pulled files
Peolite001 Aug 1, 2026
c44806d
fix: resolve typecheck errors
Peolite001 Aug 1, 2026
33ba9e3
test: add CauseCard tests to meet coverage threshold
Peolite001 Aug 1, 2026
0e09306
fix: add missing Notifications translations
Peolite001 Aug 1, 2026
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
2 changes: 0 additions & 2 deletions PRE_COMMIT_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ Extended pre-commit hook to run typecheck and affected tests.
- Performance tips
- Troubleshooting



## How It Works

### 1. Get Staged Files
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ To keep our translation files clean, you can run the unused keys script to find
```bash
node scripts/find-unused-i18n-keys.js
```

This script will output a report of keys present in `messages/en.json` but never referenced in `src/`.

## 🐳 Docker Support
Expand Down
14 changes: 4 additions & 10 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,10 @@ const config: Config = {
coverageReporters: ["text", "lcov", "html"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^react-markdown$": "<rootDir>/src/__tests__/__mocks__/react-markdown.js",
"^remark-gfm$": "<rootDir>/src/__tests__/__mocks__/remark-gfm.js",
"^rehype-sanitize$": "<rootDir>/src/__tests__/__mocks__/rehype-sanitize.js",
},
};

const markdownEsmPattern =
"node_modules/(?!(react-markdown|remark-gfm|rehype-sanitize|hast-util-sanitize|unist-util-visit|unified|bail|is-plain-obj|trough|vfile|vfile-message|devlop|remark-parse|remark-rehype|mdast-util-to-hast|mdast-util-from-markdown|mdast-util-gfm|micromark|micromark-extension-gfm|decode-named-character-reference|character-entities|property-information|hast-util-to-jsx-runtime|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|estree-util-is-identifier-name|html-url-attributes|ccount|escape-string-regexp|markdown-table|longest-streak|trim-lines|zwitch)/)";

export default async function jestConfig() {
const nextConfig = await createJestConfig(config)();
return {
...nextConfig,
transformIgnorePatterns: [...(nextConfig.transformIgnorePatterns ?? []), markdownEsmPattern],
};
}
export default createJestConfig(config);
18 changes: 18 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,5 +566,23 @@
"opening": "Opening secure checkout…",
"error": "Could not open the payment provider. Please try again.",
"poweredBy": "Powered by {provider}"
},
"Notifications": {
"justNow": "Just now",
"mAgo": "{count}m ago",
"hAgo": "{count}h ago",
"dAgo": "{count}d ago",
"unreadLabel": "{count} unread notifications",
"title": "Notifications",
"markAllRead": "Mark all as read",
"noNotifications": "No notifications yet",
"connectWallet": "Connect wallet to view notifications",
"viewDetails": "View details",
"prefContributions": "Contributions",
"prefVerified": "Campaign Verified",
"prefRefundAvailable": "Refunds Available",
"prefRevenueDeposited": "Revenue Deposited",
"settingsAriaLabel": "Notification settings",
"settingsTitle": "Notification Preferences"
}
}
18 changes: 18 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,5 +566,23 @@
"opening": "Abriendo el pago seguro…",
"error": "No se pudo abrir el proveedor de pago. Inténtalo de nuevo.",
"poweredBy": "Con la tecnología de {provider}"
},
"Notifications": {
"justNow": "Justo ahora",
"mAgo": "hace {count}m",
"hAgo": "hace {count}h",
"dAgo": "hace {count}d",
"unreadLabel": "{count} notificaciones no leídas",
"title": "Notificaciones",
"markAllRead": "Marcar todo como leído",
"noNotifications": "No hay notificaciones",
"connectWallet": "Conecta tu billetera para ver las notificaciones",
"viewDetails": "Ver detalles",
"prefContributions": "Contribuciones",
"prefVerified": "Campaña verificada",
"prefRefundAvailable": "Reembolsos disponibles",
"prefRevenueDeposited": "Ingresos depositados",
"settingsAriaLabel": "Configuración de notificaciones",
"settingsTitle": "Preferencias de notificaciones"
}
}
32 changes: 16 additions & 16 deletions scripts/check-i18n.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const messagesDir = path.join(__dirname, '../messages');
const srcDir = path.join(__dirname, '../src');
const messagesDir = path.join(__dirname, "../messages");
const srcDir = path.join(__dirname, "../src");

function getAllKeys(obj, prefix = '') {
function getAllKeys(obj, prefix = "") {
return Object.keys(obj).reduce((acc, key) => {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
if (typeof value === "object" && value !== null) {
acc.push(...getAllKeys(value, newKey));
} else {
acc.push(newKey);
Expand All @@ -36,19 +36,19 @@
}

function checkUnusedKeys() {
const enPath = path.join(messagesDir, 'en.json');
const enObj = JSON.parse(fs.readFileSync(enPath, 'utf8'));
const enPath = path.join(messagesDir, "en.json");
const enObj = JSON.parse(fs.readFileSync(enPath, "utf8"));
const allKeys = getAllKeys(enObj);

const files = getAllFiles(srcDir);
const fileContents = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
const fileContents = files.map((f) => fs.readFileSync(f, "utf8")).join("\n");

const unusedKeys = [];

for (const fullKey of allKeys) {
const parts = fullKey.split('.');
const parts = fullKey.split(".");
const key = parts[parts.length - 1];
const namespace = parts.length > 1 ? parts[0] : '';
const namespace = parts.length > 1 ? parts[0] : "";

// Check if the key appears in the source code
// It could be t('key') or t("key") or next-intl dynamic keys
Expand All @@ -61,17 +61,17 @@
}

// Filter out known dynamic keys to avoid false positives
const knownDynamicPrefixes = ['step_'];
const knownDynamicPrefixes = ["step_"];
const filteredUnused = unusedKeys.filter((k) => {
const key = k.split('.').pop();
const key = k.split(".").pop();
return !knownDynamicPrefixes.some((prefix) => key.startsWith(prefix));
});

if (filteredUnused.length > 0) {
console.warn('⚠️ Potentially unused translation keys found:');
console.warn("⚠️ Potentially unused translation keys found:");
filteredUnused.forEach((k) => console.warn(` - ${k}`));
} else {
console.log('✅ No unused translation keys detected.');
console.log("✅ No unused translation keys detected.");
}
}

Expand Down
44 changes: 23 additions & 21 deletions scripts/find-unused-i18n-keys.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");

function getFiles(dir, fileList = []) {
const files = fs.readdirSync(dir);
Expand All @@ -14,10 +14,10 @@ function getFiles(dir, fileList = []) {
return fileList;
}

function flattenKeys(obj, prefix = '') {
function flattenKeys(obj, prefix = "") {
return Object.keys(obj).reduce((acc, k) => {
const pre = prefix.length ? prefix + '.' : '';
if (typeof obj[k] === 'object' && obj[k] !== null) {
const pre = prefix.length ? prefix + "." : "";
if (typeof obj[k] === "object" && obj[k] !== null) {
Object.assign(acc, flattenKeys(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
Expand All @@ -27,36 +27,36 @@ function flattenKeys(obj, prefix = '') {
}

function findUnusedKeys() {
const messagesPath = path.join(__dirname, '../messages/en.json');
const srcPath = path.join(__dirname, '../src');
const messagesPath = path.join(__dirname, "../messages/en.json");
const srcPath = path.join(__dirname, "../src");

if (!fs.existsSync(messagesPath)) {
console.error('en.json not found at', messagesPath);
console.error("en.json not found at", messagesPath);
process.exit(1);
}

const enJson = JSON.parse(fs.readFileSync(messagesPath, 'utf8'));
const enJson = JSON.parse(fs.readFileSync(messagesPath, "utf8"));
const flatKeys = flattenKeys(enJson);
const keys = Object.keys(flatKeys);

const files = getFiles(srcPath);
const fileContents = files.map(f => fs.readFileSync(f, 'utf8'));
const fileContents = files.map((f) => fs.readFileSync(f, "utf8"));

const unusedKeys = [];

for (const key of keys) {
const parts = key.split('.');
const parts = key.split(".");
const leaf = parts[parts.length - 1];

// Check if the leaf key or the full key is present in any file.
let isUsed = fileContents.some(content => content.includes(leaf) || content.includes(key));
let isUsed = fileContents.some((content) => content.includes(leaf) || content.includes(key));

// Heuristic for dynamic keys (like step_connect_title or level_Bronze)
// If the exact leaf is not found, check if its underscore-separated parts are all present in a single file
if (!isUsed && leaf.includes('_')) {
const leafParts = leaf.split('_');
isUsed = fileContents.some(content => {
return leafParts.every(p => content.includes(p));
if (!isUsed && leaf.includes("_")) {
const leafParts = leaf.split("_");
isUsed = fileContents.some((content) => {
return leafParts.every((p) => content.includes(p));
});
}

Expand All @@ -67,11 +67,13 @@ function findUnusedKeys() {

if (unusedKeys.length > 0) {
console.log(`Found ${unusedKeys.length} potentially unused i18n keys:\n`);
unusedKeys.forEach(k => console.log(`- ${k}`));
console.log('\nNote: Some dynamic keys might be incorrectly flagged if they are constructed in complex ways.');
unusedKeys.forEach((k) => console.log(`- ${k}`));
console.log(
"\nNote: Some dynamic keys might be incorrectly flagged if they are constructed in complex ways.",
);
// Don't exit with error code so it doesn't fail CI if wired later
} else {
console.log('No unused i18n keys found! 🎉');
console.log("No unused i18n keys found! 🎉");
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/__mocks__/react-markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function ReactMarkdown(props) {
return <div data-testid="react-markdown">{props.children}</div>;
};
3 changes: 3 additions & 0 deletions src/__tests__/__mocks__/rehype-sanitize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function rehypeSanitize() {
return {};
};
3 changes: 3 additions & 0 deletions src/__tests__/__mocks__/remark-gfm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function remarkGfm() {
return {};
};
59 changes: 59 additions & 0 deletions src/__tests__/components/CauseCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jest.mock("@/hooks/useSavedCampaigns", () => ({
jest.mock("@/components/ToastProvider", () => ({
useToast: () => ({
showError: jest.fn(),
showWarning: jest.fn(),
}),
}));

Expand Down Expand Up @@ -364,3 +365,61 @@ describe("static card content", () => {
expect(screen.getByTestId("status-badge")).toHaveTextContent("funded");
});
});

// ── Error handling ────────────────────────────────────────────────────────────

describe("error handling", () => {
it("hits the catch block when voting fails", async () => {
const onVote = jest.fn(() => Promise.reject(new Error("Vote failed")));
renderCard(makeCampaign({ id: 5, status: "active" }), CONTRIBUTOR, { onVote });
fireEvent.click(screen.getByTestId("voting-component"));
await waitFor(() => expect(onVote).toHaveBeenCalled());
});

it("hits the catch block when cancelling fails", async () => {
const onCancel = jest.fn(() => Promise.reject(new Error("Cancel failed")));
renderCard(makeCampaign({ id: 42, status: "active" }), CREATOR, { onCancel });
fireEvent.click(screen.getByRole("button", { name: /cancel campaign/i }));
fireEvent.click(screen.getByRole("button", { name: /confirm cancel/i }));
await waitFor(() => expect(onCancel).toHaveBeenCalled());
});

it("hits the catch block when claiming refund fails", async () => {
const onClaimRefund = jest.fn(() => Promise.reject(new Error("Refund failed")));
renderCard(makeCampaign({ id: 7, status: "cancelled" }), CONTRIBUTOR, { onClaimRefund });
fireEvent.click(screen.getByRole("button", { name: /claim refund/i }));
await waitFor(() => expect(onClaimRefund).toHaveBeenCalled());
});
});

// ── Save Campaign ────────────────────────────────────────────────────────────

describe("Save Campaign", () => {
it("calls toggleSaved when save button is clicked with connected wallet", () => {
renderCard(makeCampaign({ id: 10 }), CONTRIBUTOR);
const saveBtn = screen.getByTitle(/Save campaign/i);
fireEvent.click(saveBtn);
});

it("does not call toggleSaved when save button is clicked without wallet", () => {
renderCard(makeCampaign({ id: 10 }), null);
const saveBtn = screen.getByTitle(/Save campaign/i);
fireEvent.click(saveBtn);
});
});

// ── Cover Image and Category Icons ───────────────────────────────────────────

describe("Cover Image and Category Icons", () => {
it("renders cover image when provided", () => {
renderCard(makeCampaign({ cover_image_url: "https://example.com/image.jpg" }));
const img = screen.getByAltText("Test Campaign");
expect(img).toHaveAttribute("src", "https://example.com/image.jpg");
});

it("renders category icon when category matches", () => {
renderCard(makeCampaign({ category: "environment" as any }));
const elements = screen.getAllByText(/🌱/);
expect(elements.length).toBeGreaterThan(0);
});
});
6 changes: 5 additions & 1 deletion src/__tests__/hooks/usePlatformFee.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { usePlatformFee, DEFAULT_PLATFORM_FEE_BPS, PLATFORM_FEE_QUERY_KEY } from "@/hooks/usePlatformFee";
import {
usePlatformFee,
DEFAULT_PLATFORM_FEE_BPS,
PLATFORM_FEE_QUERY_KEY,
} from "@/hooks/usePlatformFee";

jest.mock("@/lib/contractClient", () => ({
getPlatformFee: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/integration/AppPageComponents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ jest.mock("@/lib/contractClient", () => ({
verifyCampaignWithVotes: jest.fn(),
getContribution: jest.fn(() => Promise.resolve(15_000_000n)),
claimRefund: jest.fn(),
getAllCampaigns: jest.fn(() => Promise.resolve([])),
}));

jest.mock("@/lib/adminLog", () => ({
Expand Down
14 changes: 13 additions & 1 deletion src/__tests__/integration/CausesFilterUrlSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ jest.mock("@/lib/contractClient", () => ({
claimRefund: jest.fn(),
voteOnCampaign: jest.fn(),
hasVoted: jest.fn(),
getApproveVotes: jest.fn(() => Promise.resolve(0)),
getRejectVotes: jest.fn(() => Promise.resolve(0)),
}));

jest.mock("@/components/CauseCard", () => ({
Expand All @@ -95,6 +97,9 @@ describe("Causes filters URL sync", () => {
it("syncs category, status, sort and search to URL", async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<CausesClient />);
await act(async () => {
await Promise.resolve();
});

const [statusSelect, sortSelect] = screen.getAllByRole("combobox");
await user.click(screen.getByRole("button", { name: "Learner, 1 causes" }));
Expand All @@ -120,6 +125,10 @@ describe("Causes filters URL sync", () => {
);

render(<CausesClient />);
await act(async () => {
await Promise.resolve();
});

const [statusSelect, sortSelect] = screen.getAllByRole("combobox");

expect(await screen.findByDisplayValue("astro")).toBeInTheDocument();
Expand All @@ -131,8 +140,11 @@ describe("Causes filters URL sync", () => {
expect(sortSelect).toHaveValue("most_funded");
});

it("shows live category counts on filter chips", () => {
it("shows live category counts on filter chips", async () => {
render(<CausesClient />);
await act(async () => {
await Promise.resolve();
});

expect(
screen.getByRole("button", { name: "All Categories, 1 causes, selected" }),
Expand Down
Loading
Loading