diff --git a/PRE_COMMIT_SETUP.md b/PRE_COMMIT_SETUP.md
index 3204fa17..75d90959 100644
--- a/PRE_COMMIT_SETUP.md
+++ b/PRE_COMMIT_SETUP.md
@@ -36,8 +36,6 @@ Extended pre-commit hook to run typecheck and affected tests.
- Performance tips
- Troubleshooting
-
-
## How It Works
### 1. Get Staged Files
diff --git a/README.md b/README.md
index a0da6d7f..79b88549 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/messages/en.json b/messages/en.json
index 0ccad41a..56bc144e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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})",
diff --git a/messages/es.json b/messages/es.json
index bd884e70..e261621e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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})",
diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs
index a9b39974..ac3c69e2 100644
--- a/scripts/check-i18n.mjs
+++ b/scripts/check-i18n.mjs
@@ -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);
@@ -36,19 +36,19 @@ function getAllFiles(dir, files = []) {
}
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
@@ -61,17 +61,17 @@ function checkUnusedKeys() {
}
// 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.");
}
}
diff --git a/scripts/find-unused-i18n-keys.js b/scripts/find-unused-i18n-keys.js
index a1b6c6d0..3c96bacf 100644
--- a/scripts/find-unused-i18n-keys.js
+++ b/scripts/find-unused-i18n-keys.js
@@ -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);
@@ -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];
@@ -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));
});
}
@@ -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! 🎉");
}
}
diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx
index 0fd932de..f5f31141 100644
--- a/src/__tests__/hooks/usePlatformFee.test.tsx
+++ b/src/__tests__/hooks/usePlatformFee.test.tsx
@@ -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(),
diff --git a/src/app/api/admin-audit-log/route.ts b/src/app/api/admin-audit-log/route.ts
index 92f581f2..6fe1a1b6 100644
--- a/src/app/api/admin-audit-log/route.ts
+++ b/src/app/api/admin-audit-log/route.ts
@@ -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;
diff --git a/src/components/CampaignMap.tsx b/src/components/CampaignMap.tsx
index 3d6cc5bc..3e959ea3 100644
--- a/src/components/CampaignMap.tsx
+++ b/src/components/CampaignMap.tsx
@@ -83,9 +83,7 @@ export default function CampaignMap({ campaigns }: CampaignMapProps) {
@@ -113,7 +111,9 @@ export default function ContributorLeaderboard({
{item.truncatedAddress}
{(() => {
- 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 (
@@ -164,9 +164,7 @@ export default function ContributorLeaderboard({
-
- {t("optOutTooltip")}
-
+ {t("optOutTooltip")}
diff --git a/src/components/DonatorBadges.tsx b/src/components/DonatorBadges.tsx
index 257613f5..744df78b 100644
--- a/src/components/DonatorBadges.tsx
+++ b/src/components/DonatorBadges.tsx
@@ -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;
@@ -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 (
@@ -26,7 +26,10 @@ export function DonatorBadges({
- {t("level", { levelNumber: profile.levelNumber, levelName: tGamification(`level_${profile.levelId}`) })}
+ {t("level", {
+ levelNumber: profile.levelNumber,
+ levelName: tGamification(`level_${profile.levelId}`),
+ })}
{t("totalXlm", { amount: profile.totalDonated })}
@@ -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"
}`}
>
{badge.icon}
{tGamification(badge.name)}
- {badge.unlocked ? tGamification(badge.description) : tGamification('locked')}
+ {badge.unlocked ? tGamification(badge.description) : tGamification("locked")}
diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx
index a6912d4a..871f4465 100644
--- a/src/components/NotificationSettings.tsx
+++ b/src/components/NotificationSettings.tsx
@@ -26,12 +26,15 @@ export default function NotificationSettings() {
[publicKey],
);
- const PREF_LABELS: Record
= useMemo(() => ({
- contributions: t("prefContributions"),
- verified: t("prefVerified"),
- refundAvailable: t("prefRefundAvailable"),
- revenueDeposited: t("prefRevenueDeposited"),
- }), [t]);
+ const PREF_LABELS: Record = useMemo(
+ () => ({
+ contributions: t("prefContributions"),
+ verified: t("prefVerified"),
+ refundAvailable: t("prefRefundAvailable"),
+ revenueDeposited: t("prefRevenueDeposited"),
+ }),
+ [t],
+ );
const [localPrefs, setLocalPrefs] = useState(null);
const prefs = localPrefs ?? storedPrefs;
diff --git a/src/components/WalletContext.tsx b/src/components/WalletContext.tsx
index 7734a6ea..b38ddd64 100644
--- a/src/components/WalletContext.tsx
+++ b/src/components/WalletContext.tsx
@@ -1,7 +1,15 @@
"use client";
import * as StellarSdk from "@stellar/stellar-sdk";
import { getAddress, getNetwork, isConnected, isAllowed } from "@stellar/freighter-api";
-import React, { createContext, useContext, useEffect, useState, useMemo, ReactNode, useRef } from "react";
+import React, {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useMemo,
+ ReactNode,
+ useRef,
+} from "react";
import { useToast } from "./ToastProvider";
import { useQueryClient } from "@tanstack/react-query";
import { IS_MOCK_MODE } from "@/lib/runtimeEnv";
@@ -370,7 +378,15 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
isSocialLoginAvailable: isSocialLoginConfigured(),
connectWithSocial,
}),
- [publicKey, isWalletConnected, walletNetworkWarning, isLoading, walletKind, socialProfile, connectWithSocial]
+ [
+ publicKey,
+ isWalletConnected,
+ walletNetworkWarning,
+ isLoading,
+ walletKind,
+ socialProfile,
+ connectWithSocial,
+ ],
);
return (
diff --git a/src/context/DonationContext.tsx b/src/context/DonationContext.tsx
index c0cc702b..10d7ea1b 100644
--- a/src/context/DonationContext.tsx
+++ b/src/context/DonationContext.tsx
@@ -1,11 +1,13 @@
-'use client';
+"use client";
-import React, { createContext, useContext, useMemo, ReactNode } from 'react';
-import { useDonationGracePeriod, PendingDonation } from '../hooks/useDonationGracePeriod';
+import React, { createContext, useContext, useMemo, ReactNode } from "react";
+import { useDonationGracePeriod, PendingDonation } from "../hooks/useDonationGracePeriod";
interface DonationContextType {
pendingDonations: PendingDonation[];
- startGracePeriod: (donation: Omit) => PendingDonation;
+ startGracePeriod: (
+ donation: Omit,
+ ) => PendingDonation;
cancelDonation: (id: string) => PendingDonation | undefined;
finalizeDonation: (id: string) => void;
}
@@ -24,7 +26,7 @@ export function DonationProvider({ children }: { children: ReactNode }) {
cancelDonation,
finalizeDonation,
}),
- [pendingDonations, startGracePeriod, cancelDonation, finalizeDonation]
+ [pendingDonations, startGracePeriod, cancelDonation, finalizeDonation],
);
return {children};
@@ -33,7 +35,7 @@ export function DonationProvider({ children }: { children: ReactNode }) {
export function useDonationContext() {
const context = useContext(DonationContext);
if (!context) {
- throw new Error('useDonationContext must be used within a DonationProvider');
+ throw new Error("useDonationContext must be used within a DonationProvider");
}
return context;
}
diff --git a/src/hooks/useDevMockScenario.ts b/src/hooks/useDevMockScenario.ts
index df43b677..6cccfd7c 100644
--- a/src/hooks/useDevMockScenario.ts
+++ b/src/hooks/useDevMockScenario.ts
@@ -2,14 +2,7 @@ import { useEffect, useState } from "react";
import { IS_MOCK_MODE } from "@/lib/runtimeEnv";
export type MockScenario =
- | "default"
- | "active"
- | "verified"
- | "funded"
- | "cancelled"
- | "failed"
- | "empty"
- | "error";
+ "default" | "active" | "verified" | "funded" | "cancelled" | "failed" | "empty" | "error";
/**
* Hook to get the current mock scenario for a campaign.
diff --git a/src/hooks/useDonationGracePeriod.ts b/src/hooks/useDonationGracePeriod.ts
index 36eb0d9f..d62ec55d 100644
--- a/src/hooks/useDonationGracePeriod.ts
+++ b/src/hooks/useDonationGracePeriod.ts
@@ -1,6 +1,6 @@
-'use client';
+"use client";
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback } from "react";
export interface PendingDonation {
id: string;
@@ -28,7 +28,7 @@ export function useDonationGracePeriod(gracePeriodMs: number = DEFAULT_GRACE_PER
}, []);
const startGracePeriod = useCallback(
- (donation: Omit) => {
+ (donation: Omit) => {
const now = Date.now();
const newDonation: PendingDonation = {
...donation,
@@ -40,7 +40,7 @@ export function useDonationGracePeriod(gracePeriodMs: number = DEFAULT_GRACE_PER
setPendingDonations((prev) => [newDonation, ...prev]);
return newDonation;
},
- [gracePeriodMs]
+ [gracePeriodMs],
);
const cancelDonation = useCallback((id: string) => {
diff --git a/src/lib/adminLog.ts b/src/lib/adminLog.ts
index 273f8384..ec40e76b 100644
--- a/src/lib/adminLog.ts
+++ b/src/lib/adminLog.ts
@@ -1,10 +1,7 @@
import { normalizeAddress } from "./stellar";
export type AdminAuditAction =
- | "verify_campaign"
- | "reject_campaign"
- | "update_platform_fee"
- | "transfer_admin";
+ "verify_campaign" | "reject_campaign" | "update_platform_fee" | "transfer_admin";
export interface AdminAuditLogEntry {
adminAddress: string;
diff --git a/src/lib/contractClient.ts b/src/lib/contractClient.ts
index 4b724f7f..a09298a7 100644
--- a/src/lib/contractClient.ts
+++ b/src/lib/contractClient.ts
@@ -40,12 +40,7 @@ const NETWORK_PASSPHRASE =
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
export type TransactionLifecyclePhase =
- | "building"
- | "signing"
- | "submitting"
- | "confirming"
- | "confirmed"
- | "failed";
+ "building" | "signing" | "submitting" | "confirming" | "confirmed" | "failed";
export interface TransactionLifecycleUpdate {
phase: TransactionLifecyclePhase;
diff --git a/src/lib/gamification.ts b/src/lib/gamification.ts
index 4e71e864..d2dd6673 100644
--- a/src/lib/gamification.ts
+++ b/src/lib/gamification.ts
@@ -19,17 +19,17 @@ export interface UserGamificationProfile {
}
export const LEVEL_THRESHOLDS = [
- { levelId: 'Bronze', levelNumber: 1, min: 0, max: 100 },
- { levelId: 'Silver', levelNumber: 2, min: 100, max: 500 },
- { levelId: 'Gold', levelNumber: 3, min: 500, max: 2000 },
- { levelId: 'Platinum', levelNumber: 4, min: 2000, max: 5000 },
- { levelId: 'Diamond', levelNumber: 5, min: 5000, max: Infinity },
+ { levelId: "Bronze", levelNumber: 1, min: 0, max: 100 },
+ { levelId: "Silver", levelNumber: 2, min: 100, max: 500 },
+ { levelId: "Gold", levelNumber: 3, min: 500, max: 2000 },
+ { levelId: "Platinum", levelNumber: 4, min: 2000, max: 5000 },
+ { levelId: "Diamond", levelNumber: 5, min: 5000, max: Infinity },
];
export function calculateGamificationProfile(
totalDonated: number,
donationCount: number = 0,
- isEarlyBacker: boolean = false
+ isEarlyBacker: boolean = false,
): UserGamificationProfile {
let currentLevel = LEVEL_THRESHOLDS[0];
@@ -41,44 +41,45 @@ export function calculateGamificationProfile(
const nextLevel = LEVEL_THRESHOLDS.find((t) => t.levelNumber === currentLevel.levelNumber + 1);
const nextLevelThreshold = nextLevel ? nextLevel.min : currentLevel.max;
-
+
const currentLevelRange = (nextLevel ? nextLevel.min : currentLevel.max) - currentLevel.min;
const progressAmount = Math.max(0, totalDonated - currentLevel.min);
- const progressPercent = currentLevelRange === Infinity
- ? 100
- : Math.min(100, Math.round((progressAmount / currentLevelRange) * 100));
+ const progressPercent =
+ currentLevelRange === Infinity
+ ? 100
+ : Math.min(100, Math.round((progressAmount / currentLevelRange) * 100));
const badges: Badge[] = [
{
- id: 'early_backer',
- name: 'badge_early_backer_name',
- description: 'badge_early_backer_desc',
- icon: '🌱',
- color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/30',
+ id: "early_backer",
+ name: "badge_early_backer_name",
+ description: "badge_early_backer_desc",
+ icon: "🌱",
+ color: "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
unlocked: isEarlyBacker || donationCount > 0,
},
{
- id: 'streak_master',
- name: 'badge_streak_master_name',
- description: 'badge_streak_master_desc',
- icon: '🔥',
- color: 'bg-amber-500/10 text-amber-500 border-amber-500/30',
+ id: "streak_master",
+ name: "badge_streak_master_name",
+ description: "badge_streak_master_desc",
+ icon: "🔥",
+ color: "bg-amber-500/10 text-amber-500 border-amber-500/30",
unlocked: donationCount >= 3,
},
{
- id: 'whale',
- name: 'badge_whale_name',
- description: 'badge_whale_desc',
- icon: '🐋',
- color: 'bg-blue-500/10 text-blue-500 border-blue-500/30',
+ id: "whale",
+ name: "badge_whale_name",
+ description: "badge_whale_desc",
+ icon: "🐋",
+ color: "bg-blue-500/10 text-blue-500 border-blue-500/30",
unlocked: totalDonated >= 1000,
},
{
- id: 'heart_champion',
- name: 'badge_heart_champion_name',
- description: 'badge_heart_champion_desc',
- icon: '💎',
- color: 'bg-purple-500/10 text-purple-500 border-purple-500/30',
+ id: "heart_champion",
+ name: "badge_heart_champion_name",
+ description: "badge_heart_champion_desc",
+ icon: "💎",
+ color: "bg-purple-500/10 text-purple-500 border-purple-500/30",
unlocked: totalDonated >= 5000,
},
];
diff --git a/src/lib/observability/metricsStore.ts b/src/lib/observability/metricsStore.ts
index 5ba6947f..229bdfad 100644
--- a/src/lib/observability/metricsStore.ts
+++ b/src/lib/observability/metricsStore.ts
@@ -6,6 +6,8 @@ import type {
} from "./types";
const MAX_EVENTS = 2_000;
+/** Events older than this TTL are purged on ingest and snapshot reads. */
+const MAX_AGE_MS = 60 * 60 * 1_000; // 1 hour
const DEFAULT_WINDOW_MS = 5 * 60_000;
const events: ObservabilityEvent[] = [];
@@ -17,8 +19,24 @@ function countBy(items: T[]): Record {
}, {});
}
+/** Remove events whose timestamp is older than MAX_AGE_MS from now. */
+export function purgeStaleEvents(now: number = Date.now()): void {
+ const cutoff = now - MAX_AGE_MS;
+ // events is kept in insertion order, so find the first index past the cutoff
+ const firstAlive = events.findIndex((e) => new Date(e.timestamp).getTime() >= cutoff);
+ if (firstAlive > 0) {
+ // Some stale events at the front — splice them out
+ events.splice(0, firstAlive);
+ } else if (firstAlive === -1) {
+ // No events within the TTL window — clear the entire buffer
+ events.length = 0;
+ }
+}
+
export function ingestObservabilityEvent(event: ObservabilityEvent): void {
events.push(event);
+ // Enforce both TTL and size bounds
+ purgeStaleEvents();
if (events.length > MAX_EVENTS) {
events.splice(0, events.length - MAX_EVENTS);
}
@@ -119,6 +137,8 @@ export function evaluateAlerts(rates: ObservabilityRatesSnapshot): Observability
export function getObservabilityMetricsSnapshot(
windowMs: number = DEFAULT_WINDOW_MS,
): ObservabilityMetricsSnapshot {
+ // Purge stale events before computing the snapshot so the result is clean
+ purgeStaleEvents();
const windowEvents = eventsInWindow(windowMs);
const rates = computeRates(windowEvents, windowMs);
diff --git a/tests/e2e/withdrawal.spec.ts b/tests/e2e/withdrawal.spec.ts
index 4afa4d03..777c5123 100644
--- a/tests/e2e/withdrawal.spec.ts
+++ b/tests/e2e/withdrawal.spec.ts
@@ -20,11 +20,16 @@ test.describe("Creator Withdrawal Flow E2E Test", () => {
// Dismiss onboarding tour and pre-set connected wallet state
await page.addInitScript(() => {
localStorage.setItem("onboarding_tour_dismissed", "1");
- localStorage.setItem("stellar_wallet_public_key", "GCREATOR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123");
+ localStorage.setItem(
+ "stellar_wallet_public_key",
+ "GCREATOR1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123",
+ );
});
});
- test("should allow creator to navigate to dashboard and trigger withdrawal flow", async ({ page }) => {
+ test("should allow creator to navigate to dashboard and trigger withdrawal flow", async ({
+ page,
+ }) => {
// Step 1: Navigate to Dashboard page
await page.goto("/en/dashboard");
await expect(page).toHaveURL(/\/dashboard/);
@@ -35,7 +40,9 @@ test.describe("Creator Withdrawal Flow E2E Test", () => {
await expect(dashboardHeader).toBeVisible();
// Step 3: Check for withdrawal action button or navigate directly to withdraw tab
- const withdrawBtn = page.getByRole("button", { name: /withdraw|claim/i }).or(page.locator("body"));
+ const withdrawBtn = page
+ .getByRole("button", { name: /withdraw|claim/i })
+ .or(page.locator("body"));
await expect(withdrawBtn).toBeVisible();
// Step 4: Validate mock mode response and withdrawal UI readiness