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
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
18 changes: 18 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,24 @@
"verifyWithVotes": "✓ Verify Campaign with Votes",
"verifying": "Verifying..."
},
"Notifications": {
"title": "Notifications",
"markAllRead": "Mark all read",
"noNotifications": "No notifications yet",
"connectWallet": "Connect your wallet to see notifications",
"unreadLabel": "{count} unread 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 to my campaign",
"prefVerified": "Campaign verified",
"prefRefundAvailable": "Refund available",
"prefRevenueDeposited": "Revenue deposited"
},
"Observability": {
"loading": "Loading observability metrics…",
"metricsRequestFailed": "Metrics request failed ({status})",
Expand Down
18 changes: 18 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,24 @@
"verifyWithVotes": "✓ Verificar Campaña con Votos",
"verifying": "Verificando..."
},
"Notifications": {
"title": "Notificaciones",
"markAllRead": "Marcar todo leído",
"noNotifications": "Aún no hay notificaciones",
"connectWallet": "Conecta tu wallet para ver notificaciones",
"unreadLabel": "{count} notificaciones sin leer",
"viewDetails": "Ver detalles",
"justNow": "Ahora mismo",
"mAgo": "hace {count}m",
"hAgo": "hace {count}h",
"dAgo": "hace {count}d",
"settingsAriaLabel": "Configuración de notificaciones",
"settingsTitle": "Configuración de Notificaciones",
"prefContributions": "Contribuciones a mi campaña",
"prefVerified": "Campaña verificada",
"prefRefundAvailable": "Reembolso disponible",
"prefRevenueDeposited": "Ingresos depositados"
},
"Observability": {
"loading": "Cargando métricas de observabilidad…",
"metricsRequestFailed": "Error al solicitar métricas ({status})",
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 notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable namespace.

// 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
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
5 changes: 1 addition & 4 deletions src/app/api/admin-audit-log/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import { NextResponse } from "next/server";
export const runtime = "nodejs";

type AdminAuditAction =
| "verify_campaign"
| "reject_campaign"
| "update_platform_fee"
| "transfer_admin";
"verify_campaign" | "reject_campaign" | "update_platform_fee" | "transfer_admin";

interface AdminAuditLogEntry {
adminAddress: string;
Expand Down
4 changes: 1 addition & 3 deletions src/components/CampaignMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ export default function CampaignMap({ campaigns }: CampaignMapProps) {
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-2">
{t("emptyTitle")}
</h3>
<p className="text-zinc-600 dark:text-zinc-400 max-w-md">
{t("emptyBody")}
</p>
<p className="text-zinc-600 dark:text-zinc-400 max-w-md">{t("emptyBody")}</p>
</div>
);
}
Expand Down
11 changes: 4 additions & 7 deletions src/components/CancelDonationBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
"use client";

import React, { useState, useEffect } from 'react';
import { PendingDonation } from '../hooks/useDonationGracePeriod';
import React, { useState, useEffect } from "react";
import { PendingDonation } from "../hooks/useDonationGracePeriod";

interface CancelDonationBannerProps {
pendingDonations: PendingDonation[];
Expand Down Expand Up @@ -31,10 +31,7 @@ export function CancelDonationBanner({
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full px-4"
>
{pendingDonations.map((donation) => {
const remainingSeconds = Math.max(
0,
Math.ceil((donation.expiresAt - Date.now()) / 1000)
);
const remainingSeconds = Math.max(0, Math.ceil((donation.expiresAt - Date.now()) / 1000));

return (
<div
Expand Down
12 changes: 5 additions & 7 deletions src/components/ContributorLeaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,7 @@ export default function ContributorLeaderboard({

{contributors.length === 0 ? (
<div className="text-center py-6 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("emptyMessage")}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("emptyMessage")}</p>
</div>
) : (
<ul className="space-y-2.5" aria-label="Top supporters list">
Expand All @@ -113,7 +111,9 @@ export default function ContributorLeaderboard({
<p className="font-mono text-zinc-800 dark:text-zinc-200 truncate flex items-center gap-1.5">
<span>{item.truncatedAddress}</span>
{(() => {
const amountXlm = item.totalAmountStroops ? Number(item.totalAmountStroops) / 10_000_000 : 0;
const amountXlm = item.totalAmountStroops
? Number(item.totalAmountStroops) / 10_000_000
: 0;
const profile = calculateGamificationProfile(amountXlm);
return (
<span className="px-1.5 py-0.5 rounded bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-sans font-medium border border-rose-500/20">
Expand Down Expand Up @@ -164,9 +164,7 @@ export default function ContributorLeaderboard({

<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-tight flex items-start gap-1">
<ShieldCheck className="w-3.5 h-3.5 text-zinc-400 shrink-0 mt-0.5" />
<span>
{t("optOutTooltip")}
</span>
<span>{t("optOutTooltip")}</span>
</p>
</div>
</div>
Expand Down
21 changes: 12 additions & 9 deletions src/components/DonatorBadges.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';
"use client";

import React from 'react';
import { calculateGamificationProfile } from '../lib/gamification';
import { useTranslations } from 'next-intl';
import React from "react";
import { calculateGamificationProfile } from "../lib/gamification";
import { useTranslations } from "next-intl";

interface DonatorBadgesProps {
totalDonated: number;
Expand All @@ -15,8 +15,8 @@ export function DonatorBadges({
donationCount = 0,
isEarlyBacker = false,
}: DonatorBadgesProps) {
const t = useTranslations('DonatorBadges');
const tGamification = useTranslations('Gamification');
const t = useTranslations("DonatorBadges");
const tGamification = useTranslations("Gamification");
const profile = calculateGamificationProfile(totalDonated, donationCount, isEarlyBacker);

return (
Expand All @@ -26,7 +26,10 @@ export function DonatorBadges({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-rose-500/20 text-rose-400 border border-rose-500/30">
{t("level", { levelNumber: profile.levelNumber, levelName: tGamification(`level_${profile.levelId}`) })}
{t("level", {
levelNumber: profile.levelNumber,
levelName: tGamification(`level_${profile.levelId}`),
})}
</span>
<span className="text-xs text-slate-400">
{t("totalXlm", { amount: profile.totalDonated })}
Expand Down Expand Up @@ -60,14 +63,14 @@ export function DonatorBadges({
className={`flex items-center gap-2.5 p-2.5 rounded-xl border transition-all ${
badge.unlocked
? `${badge.color} shadow-sm`
: 'bg-slate-900/40 text-slate-500 border-slate-800/60 opacity-60'
: "bg-slate-900/40 text-slate-500 border-slate-800/60 opacity-60"
}`}
>
<span className="text-xl leading-none">{badge.icon}</span>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">{tGamification(badge.name)}</span>
<span className="text-[10px] text-slate-400 truncate">
{badge.unlocked ? tGamification(badge.description) : tGamification('locked')}
{badge.unlocked ? tGamification(badge.description) : tGamification("locked")}
</span>
</div>
</div>
Expand Down
15 changes: 9 additions & 6 deletions src/components/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ export default function NotificationSettings() {
[publicKey],
);

const PREF_LABELS: Record<keyof NotificationPreferences, string> = useMemo(() => ({
contributions: t("prefContributions"),
verified: t("prefVerified"),
refundAvailable: t("prefRefundAvailable"),
revenueDeposited: t("prefRevenueDeposited"),
}), [t]);
const PREF_LABELS: Record<keyof NotificationPreferences, string> = useMemo(
() => ({
contributions: t("prefContributions"),
verified: t("prefVerified"),
refundAvailable: t("prefRefundAvailable"),
revenueDeposited: t("prefRevenueDeposited"),
}),
[t],
);
const [localPrefs, setLocalPrefs] = useState<NotificationPreferences | null>(null);

const prefs = localPrefs ?? storedPrefs;
Expand Down
Loading
Loading