Skip to content
Open
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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
{}
{
}
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
9 changes: 7 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ const config: Config = {
};

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)/)";
"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|style-to-js|unist-util-position)/)";

export default async function jestConfig() {
const nextConfig = await createJestConfig(config)();
// Replace Next.js's default node_modules exclusion pattern with our own that
// allows ESM-only markdown packages to be transformed by Babel/SWC.
const filtered = (nextConfig.transformIgnorePatterns ?? []).filter(
(p) => !p.includes("node_modules"),
);
return {
...nextConfig,
transformIgnorePatterns: [...(nextConfig.transformIgnorePatterns ?? []), markdownEsmPattern],
transformIgnorePatterns: [...filtered, markdownEsmPattern],
};
}
18 changes: 18 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,24 @@
"badge_heart_champion_desc": "Reached Diamond level (5,000+ XLM donated)",
"locked": "Locked"
},
"Notifications": {
"title": "Notifications",
"unreadLabel": "{count} unread notifications",
"markAllRead": "Mark all read",
"noNotifications": "No notifications yet",
"connectWallet": "Connect your wallet to see notifications",
"viewDetails": "View details",
"justNow": "Just now",
"mAgo": "{count}m ago",
"hAgo": "{count}h ago",
"dAgo": "{count}d ago",
"settingsAriaLabel": "Notification settings",
"settingsTitle": "Notification settings",
"prefContributions": "Contributions",
"prefVerified": "Campaign verification",
"prefRefundAvailable": "Refunds available",
"prefRevenueDeposited": "Revenue deposits"
},
"ContributorLeaderboard": {
"title": "Top Supporters",
"subtitle": "Leaderboard",
Expand Down
18 changes: 18 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,24 @@
"badge_heart_champion_desc": "Alcanzó el nivel Diamante (5,000+ XLM donados)",
"locked": "Bloqueado"
},
"Notifications": {
"title": "Notificaciones",
"unreadLabel": "{count} notificaciones sin leer",
"markAllRead": "Marcar todas como leidas",
"noNotifications": "Aun no hay notificaciones",
"connectWallet": "Conecta tu billetera para ver notificaciones",
"viewDetails": "Ver detalles",
"justNow": "Ahora mismo",
"mAgo": "Hace {count} min",
"hAgo": "Hace {count} h",
"dAgo": "Hace {count} d",
"settingsAriaLabel": "Configuracion de notificaciones",
"settingsTitle": "Configuracion de notificaciones",
"prefContributions": "Contribuciones",
"prefVerified": "Verificacion de campana",
"prefRefundAvailable": "Reembolsos disponibles",
"prefRevenueDeposited": "Depositos de ingresos"
},
"ContributorLeaderboard": {
"title": "Principales Apoyadores",
"subtitle": "Tabla de clasificación",
Expand Down
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default defineConfig({
reporter: process.env.CI ? [["github"], ["list"], ["html"]] : [["list"]],

use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
baseURL: process.env.BASE_URL || "http://127.0.0.1:3000",
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.CI ? "retain-on-failure" : "off",
Expand Down Expand Up @@ -45,7 +45,7 @@ export default defineConfig({

webServer: {
command: "npm run dev",
url: "http://localhost:3000",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 120000,
stdout: "pipe",
Expand Down
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
2 changes: 2 additions & 0 deletions src/__tests__/app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe("sitemap", () => {
{
id: 42,
created_at: 1_700_000_000,
is_active: true,
is_cancelled: false,
} as Awaited<ReturnType<typeof getAllCampaigns>>[number],
]);

Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/components/CommentItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import CommentItem from "@/components/CommentItem";
import { useWallet } from "@/components/WalletContext";
import { ToastProvider } from "@/components/ToastProvider";

jest.mock("@/components/WalletContext", () => ({
useWallet: jest.fn(),
Expand All @@ -11,6 +12,10 @@ jest.mock("@/lib/campaignComments", () => ({
verifyCommentSignature: jest.fn().mockResolvedValue(true),
}));

function renderWithProviders(ui: React.ReactElement) {
return render(<ToastProvider>{ui}</ToastProvider>);
}

describe("CommentItem", () => {
const mockComment = {
id: "c1",
Expand Down Expand Up @@ -122,7 +127,7 @@ describe("CommentItem", () => {
});

it("opens reply form when Reply is clicked", () => {
render(
renderWithProviders(
<CommentItem
comment={mockComment}
isCreator={false}
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/components/ContributorLeaderboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { render, screen, fireEvent } from "@testing-library/react";
import ContributorLeaderboard from "@/components/ContributorLeaderboard";

const mockCalculateGamificationProfileFromStroops = jest.fn();

jest.mock("@/lib/gamification", () => {
const actual = jest.requireActual<typeof import("@/lib/gamification")>("@/lib/gamification");
return {
...actual,
calculateGamificationProfileFromStroops: (...args: [bigint]) =>
mockCalculateGamificationProfileFromStroops(...args),
};
});

const mockUseTopContributors = jest.fn();

jest.mock("@/hooks/useTopContributors", () => ({
Expand All @@ -10,6 +21,21 @@ jest.mock("@/hooks/useTopContributors", () => ({

jest.mock("next-intl", () => ({
useLocale: () => "en",
useTranslations: () => (key: string) => {
const messages: Record<string, string> = {
title: "Top Supporters",
subtitle: "Our most generous supporters",
emptyMessage: "Be the first supporter for this cause! 💜",
youBadge: "You",
hideWallet: "Hide my wallet",
enableAnon: "Enable anonymous mode",
disableAnon: "Disable anonymous mode",
anonymousState: "Anonymous",
optOutState: "Visible",
optOutTooltip: "Your wallet can be hidden from the leaderboard.",
};
return messages[key] ?? key;
},
}));

const WALLET_1 = "GDA7X7P5H4F3R8E2M1N6K9W4L5V8Q3Z0A1B2C3D4E5F6G7H8I9J0K1L2";
Expand All @@ -19,6 +45,11 @@ describe("ContributorLeaderboard component", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockCalculateGamificationProfileFromStroops.mockImplementation((totalAmountStroops) =>
jest
.requireActual<typeof import("@/lib/gamification")>("@/lib/gamification")
.calculateGamificationProfileFromStroops(totalAmountStroops),
);
});

it("renders loading placeholders when isLoading is true", () => {
Expand Down Expand Up @@ -77,6 +108,33 @@ describe("ContributorLeaderboard component", () => {
expect(screen.getByText("🥈")).toBeInTheDocument();
});

it("recalculates a contributor tier only when their contribution total changes", () => {
const contributors = [
{
walletAddress: WALLET_1,
truncatedAddress: "GDA7X7...K1L2",
totalAmountStroops: BigInt(250_000_000_000),
isAnonymous: false,
rank: 1,
},
];
mockUseTopContributors.mockReturnValue({ contributors, isLoading: false, refetch: jest.fn() });

const { rerender } = render(<ContributorLeaderboard campaignId={1} userWalletAddress={null} />);
expect(mockCalculateGamificationProfileFromStroops).toHaveBeenCalledTimes(1);

rerender(<ContributorLeaderboard campaignId={1} userWalletAddress={null} />);
expect(mockCalculateGamificationProfileFromStroops).toHaveBeenCalledTimes(1);

mockUseTopContributors.mockReturnValue({
contributors: [{ ...contributors[0], totalAmountStroops: BigInt(260_000_000_000) }],
isLoading: false,
refetch: jest.fn(),
});
rerender(<ContributorLeaderboard campaignId={1} userWalletAddress={null} />);
expect(mockCalculateGamificationProfileFromStroops).toHaveBeenCalledTimes(2);
});

it("renders Anonymous Supporter when a contributor is marked anonymous", () => {
mockUseTopContributors.mockReturnValue({
contributors: [
Expand Down
Loading
Loading