Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
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);
17 changes: 17 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,5 +556,22 @@
"totalXlm": "{amount} XLM Total",
"nextXlm": "Next: {amount} XLM",
"earnedBadges": "Earned Badges"
},
"Notifications": {
"title": "Notifications",
"justNow": "Just now",
"mAgo": "{count}m ago",
"hAgo": "{count}h ago",
"dAgo": "{count}d ago",
"unreadLabel": "Notifications ({count} unread)",
"markAllRead": "Mark all read",
"noNotifications": "No notifications",
"connectWallet": "Connect wallet to view notifications",
"viewDetails": "View details",
"prefContributions": "Contributions to my campaigns",
"prefVerified": "Campaign verified",
"prefRefundAvailable": "Refunds available",
"prefRevenueDeposited": "Revenue deposited",
"settingsAriaLabel": "Notification settings"
}
}
17 changes: 17 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,5 +556,22 @@
"totalXlm": "{amount} XLM en Total",
"nextXlm": "Siguiente: {amount} XLM",
"earnedBadges": "Insignias Ganadas"
},
"Notifications": {
"title": "Notificaciones",
"justNow": "Justo ahora",
"mAgo": "Hace {count}m",
"hAgo": "Hace {count}h",
"dAgo": "Hace {count}d",
"unreadLabel": "Notificaciones ({count} no leídas)",
"markAllRead": "Marcar todo como leído",
"noNotifications": "No hay notificaciones",
"connectWallet": "Conecta tu billetera para ver las notificaciones",
"viewDetails": "Ver detalles",
"prefContributions": "Contribuciones a mis campañas",
"prefVerified": "Campaña verificada",
"prefRefundAvailable": "Reembolsos disponibles",
"prefRevenueDeposited": "Ingresos depositados",
"settingsAriaLabel": "Configuración 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 {};
};
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