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 777a8e86..056bc4e2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -277,6 +277,8 @@
"labelDescription": "Description",
"placeholderDescription": "Describe your campaign, what it aims to achieve, and how funds will be used",
"labelCreatorEmailOptional": "Creator Email (Optional)",
+ "labelCoverImageUrl": "Cover Image URL",
+ "optional": "(optional)",
"placeholderCreatorEmail": "name@example.com",
"creatorEmailNote": "Optional, off-chain only. Used for campaign milestone and deadline notifications.",
"labelFundingGoal": "Funding Goal (XLM)",
@@ -288,6 +290,8 @@
"revenueSharingDesc": "Allow contributors to earn a share of future revenue.",
"revenueSharingAriaLabel": "Revenue Sharing",
"labelRevenueSharePct": "Revenue Share Percentage",
+ "labelExactBps": "Exact bps:",
+ "revenueShareBpsDenominator": "/ 10 000",
"cancel": "Cancel",
"launchCampaign": "Launch Campaign",
"reviewTitle": "Review Campaign Before Signing",
@@ -321,6 +325,9 @@
"labelDescriptionLanguage": "Description Language",
"tabEnglish": "English",
"tabSpanish": "Spanish (optional)",
+ "tabWrite": "Write",
+ "tabPreview": "Preview",
+ "nothingToPreview": "Nothing to preview",
"placeholderDescriptionEs": "Describe tu campaña en español (opcional)",
"validationDescriptionEsTooLong": "Spanish description must be 1,000 characters or fewer."
},
@@ -557,6 +564,24 @@
"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": "{count} unread notifications",
+ "markAllRead": "Mark all read",
+ "noNotifications": "No notifications yet",
+ "connectWallet": "Connect wallet to see notifications",
+ "viewDetails": "View details",
+ "settingsTitle": "Notification Preferences",
+ "settingsAriaLabel": "Open notification settings",
+ "prefContributions": "Contribution confirmations",
+ "prefVerified": "Campaign verified",
+ "prefRefundAvailable": "Refund available",
+ "prefRevenueDeposited": "Revenue deposited"
+ },
"CauseDetail": {
"noDescription": "No description provided by the creator."
},
diff --git a/messages/es.json b/messages/es.json
index 9814df66..2449b8d7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -277,6 +277,8 @@
"labelDescription": "Descripción",
"placeholderDescription": "Describe tu campaña, qué pretende lograr y cómo se utilizarán los fondos",
"labelCreatorEmailOptional": "Email del Creador (Opcional)",
+ "labelCoverImageUrl": "URL de la Imagen de Portada",
+ "optional": "(opcional)",
"placeholderCreatorEmail": "nombre@ejemplo.com",
"creatorEmailNote": "Opcional, solo fuera de la cadena. Se usa para notificaciones de hitos y vencimiento de campaña.",
"labelFundingGoal": "Meta de Financiación (XLM)",
@@ -288,6 +290,8 @@
"revenueSharingDesc": "Permite a los contribuyentes ganar una parte de los ingresos futuros.",
"revenueSharingAriaLabel": "Participación en Ingresos",
"labelRevenueSharePct": "Porcentaje de Participación en Ingresos",
+ "labelExactBps": "Puntos base exactos:",
+ "revenueShareBpsDenominator": "/ 10.000",
"cancel": "Cancelar",
"launchCampaign": "Lanzar Campaña",
"reviewTitle": "Revisar Campaña Antes de Firmar",
@@ -321,6 +325,9 @@
"labelDescriptionLanguage": "Idioma de la descripción",
"tabEnglish": "Inglés",
"tabSpanish": "Español (opcional)",
+ "tabWrite": "Escribir",
+ "tabPreview": "Vista previa",
+ "nothingToPreview": "Nada que previsualizar",
"placeholderDescriptionEs": "Describe tu campaña en español (opcional)",
"validationDescriptionEsTooLong": "La descripción en español debe tener 1.000 caracteres o menos."
},
@@ -557,6 +564,24 @@
"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": "{count} notificaciones no leídas",
+ "markAllRead": "Marcar todo como leído",
+ "noNotifications": "Sin notificaciones aún",
+ "connectWallet": "Conecta tu billetera para ver notificaciones",
+ "viewDetails": "Ver detalles",
+ "settingsTitle": "Preferencias de Notificaciones",
+ "settingsAriaLabel": "Abrir configuración de notificaciones",
+ "prefContributions": "Confirmaciones de contribución",
+ "prefVerified": "Campaña verificada",
+ "prefRefundAvailable": "Reembolso disponible",
+ "prefRevenueDeposited": "Ingresos depositados"
+ },
"CauseDetail": {
"noDescription": "El creador no proporcionó una descripción."
},
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__/components/CauseCard.test.tsx b/src/__tests__/components/CauseCard.test.tsx
index b35b01d2..cd512905 100644
--- a/src/__tests__/components/CauseCard.test.tsx
+++ b/src/__tests__/components/CauseCard.test.tsx
@@ -37,16 +37,34 @@ jest.mock("@/components/DeadlineCountdown", () => ({
default: () => ,
}));
+const mockToggleSaved = jest.fn();
+const mockIsSaved = jest.fn(() => false);
+
jest.mock("@/hooks/useSavedCampaigns", () => ({
- useSavedCampaigns: () => ({ isSaved: () => false, toggleSaved: jest.fn(), savedIds: [] }),
+ useSavedCampaigns: () => ({
+ isSaved: (id: number) => mockIsSaved(id),
+ toggleSaved: (id: number) => mockToggleSaved(id),
+ savedIds: [],
+ }),
}));
+const mockShowError = jest.fn();
+const mockShowWarning = jest.fn();
+
jest.mock("@/components/ToastProvider", () => ({
useToast: () => ({
- showError: jest.fn(),
+ showError: (msg: string) => mockShowError(msg),
+ showWarning: (msg: string) => mockShowWarning(msg),
}),
}));
+beforeEach(() => {
+ mockToggleSaved.mockClear();
+ mockIsSaved.mockClear().mockReturnValue(false);
+ mockShowError.mockClear();
+ mockShowWarning.mockClear();
+});
+
jest.mock("@/components/cancelCampaignModal", () => ({
__esModule: true,
default: ({
@@ -353,6 +371,23 @@ describe("static card content", () => {
expect(screen.getByText("Helping the world.")).toBeInTheDocument();
});
+ it("trims surrounding whitespace from the description", () => {
+ renderCard(makeCampaign({ description: " Helping the world.\n" }));
+ expect(screen.getByTestId("campaign-description")).toHaveTextContent("Helping the world.");
+ });
+
+ // #645 - a description that renders as blank space leaves the same awkward
+ // gap as a missing one, so both fall back to the placeholder.
+ it.each([
+ ["empty", ""],
+ ["whitespace only", " \n\t "],
+ ["markdown punctuation with no words", "---"],
+ ])("shows the fallback when the description is %s", (_label, description) => {
+ renderCard(makeCampaign({ description }));
+ expect(screen.getByTestId("campaign-description-fallback")).toBeInTheDocument();
+ expect(screen.queryByTestId("campaign-description")).not.toBeInTheDocument();
+ });
+
it("renders a truncated creator address", () => {
renderCard(makeCampaign({ creator: "GABCDE123456789WXYZ" }));
// formatAddress keeps first 6 and last 4 chars
@@ -363,4 +398,64 @@ describe("static card content", () => {
renderCard(makeCampaign({ status: "funded" }));
expect(screen.getByTestId("status-badge")).toHaveTextContent("funded");
});
+
+ it("renders the cover image when the campaign has one", () => {
+ renderCard(makeCampaign({ cover_image_url: "https://example.com/cover.png", title: "Cover" }));
+ expect(screen.getByAltText("Cover")).toBeInTheDocument();
+ });
+
+ it("falls back to the category icon when there is no cover image", () => {
+ renderCard(makeCampaign({ cover_image_url: "", category: Category.Learner }));
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ });
+});
+
+// ── Save button ───────────────────────────────────────────────────────────────
+
+describe("save button", () => {
+ it("warns instead of saving when no wallet is connected", () => {
+ renderCard(makeCampaign(), null);
+ fireEvent.click(screen.getByTitle("Save campaign"));
+ expect(mockShowWarning).toHaveBeenCalledWith("Please connect your wallet to save campaigns.");
+ expect(mockToggleSaved).not.toHaveBeenCalled();
+ });
+
+ it("toggles the campaign when a wallet is connected", () => {
+ renderCard(makeCampaign({ id: 7 }), "GSOMEWALLET");
+ fireEvent.click(screen.getByTitle("Save campaign"));
+ expect(mockToggleSaved).toHaveBeenCalledWith(7);
+ expect(mockShowWarning).not.toHaveBeenCalled();
+ });
+
+ it('reads "Remove from saved" once the campaign is saved', () => {
+ mockIsSaved.mockReturnValue(true);
+ renderCard(makeCampaign(), "GSOMEWALLET");
+ expect(screen.getByTitle("Remove from saved")).toBeInTheDocument();
+ });
+});
+
+// ── Async action failures ─────────────────────────────────────────────────────
+
+describe("action error handling", () => {
+ it("surfaces an error when voting fails", async () => {
+ const onVote = jest.fn(() => Promise.reject(new Error("vote exploded")));
+ renderCard(makeCampaign({ status: "active" }), CONTRIBUTOR, { onVote });
+ fireEvent.click(screen.getByTestId("voting-component"));
+ await waitFor(() => expect(mockShowError).toHaveBeenCalled());
+ });
+
+ it("surfaces an error when cancelling fails", async () => {
+ const onCancel = jest.fn(() => Promise.reject(new Error("cancel exploded")));
+ renderCard(makeCampaign({ 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(mockShowError).toHaveBeenCalled());
+ });
+
+ it("surfaces an error when claiming a refund fails", async () => {
+ const onClaimRefund = jest.fn(() => Promise.reject(new Error("refund exploded")));
+ renderCard(makeCampaign({ status: "cancelled" }), CONTRIBUTOR, { onClaimRefund });
+ fireEvent.click(screen.getByRole("button", { name: /claim refund/i }));
+ await waitFor(() => expect(mockShowError).toHaveBeenCalled());
+ });
});
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/[locale]/causes/[id]/CauseDetailClient.tsx b/src/app/[locale]/causes/[id]/CauseDetailClient.tsx
index 74f89fd6..159428d0 100644
--- a/src/app/[locale]/causes/[id]/CauseDetailClient.tsx
+++ b/src/app/[locale]/causes/[id]/CauseDetailClient.tsx
@@ -1,23 +1,3 @@
-'use client';
-
-import Link from 'next/link';
-import { useState, useEffect } from 'react';
-import CampaignActions from '@/components/CampaignActions';
-import CampaignDescription from '@/components/CampaignDescription';
-import ReactMarkdown from 'react-markdown';
-import remarkGfm from 'remark-gfm';
-import rehypeSanitize from 'rehype-sanitize';
-import CampaignStatusBadge from '@/components/CampaignStatusBadge';
-import DeadlineCountdown from '@/components/DeadlineCountdown';
-import DonationModal from '@/components/DonationModal';
-import FundingProgressBar from '@/components/FundingProgressBar';
-import RevenueSharingPanel from '@/components/RevenueSharingPanel';
-import UpdatesSection from '@/components/UpdatesSection';
-import { useToast } from '@/components/ToastProvider';
-import VotingComponent from '@/components/VotingComponent';
-import { useWallet } from '@/components/WalletContext';
-import { useCampaign } from '@/hooks/useCampaign';
-import { usePlatformFee } from '@/hooks/usePlatformFee';
"use client";
import Link from "next/link";
@@ -26,23 +6,17 @@ import { notFound } from "next/navigation";
import Image from "next/image";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
-import CampaignTabs from "@/components/CampaignTabs";
-const RevenueSharingPanel = dynamic(() => import("@/components/RevenueSharingPanel"), {
- ssr: false,
-});
-const VestingReservePanel = dynamic(() => import("@/components/VestingReservePanel"), {
- ssr: false,
-});
-const DonationModal = dynamic(() => import("@/components/DonationModal"), { ssr: false });
+import { useTranslations, useLocale } from "next-intl";
+import CampaignActions from "@/components/CampaignActions";
import CampaignStatusBadge from "@/components/CampaignStatusBadge";
+import CampaignTabs from "@/components/CampaignTabs";
+import ContributorLeaderboard from "@/components/ContributorLeaderboard";
import DeadlineCountdown from "@/components/DeadlineCountdown";
import FundingProgressBar from "@/components/FundingProgressBar";
-import ContributorLeaderboard from "@/components/ContributorLeaderboard";
import RelatedCampaigns from "@/components/RelatedCampaigns";
-import ShareButtons from "@/components/ShareButtons";
-import SafeMarkdown from "@/components/SafeMarkdown";
import ReportModal from "@/components/ReportModal";
-import CampaignActions from "@/components/CampaignActions";
+import SafeMarkdown from "@/components/SafeMarkdown";
+import ShareButtons from "@/components/ShareButtons";
import AsyncButtonContent from "@/components/AsyncButtonContent";
import { useToast } from "@/components/ToastProvider";
import VotingComponent from "@/components/VotingComponent";
@@ -52,6 +26,7 @@ import { useFollowedCreators } from "@/hooks/useFollowedCreators";
import { useLiveCampaignFunding } from "@/hooks/useLiveCampaignFunding";
import { useLiveVoteTallies } from "@/hooks/useLiveVoteTallies";
import { usePlatformFee } from "@/hooks/usePlatformFee";
+import { CauseDetailSkeleton } from "@/components/Skeleton";
import {
voteOnCampaign,
hasVoted,
@@ -60,25 +35,8 @@ import {
verifyCampaignWithVotes,
getContribution,
claimRefund,
-} from '@/lib/contractClient';
-import VotingComponent from '@/components/VotingComponent';
-import CampaignStatusBadge from '@/components/CampaignStatusBadge';
-import DeadlineCountdown from '@/components/DeadlineCountdown';
-import FundingProgressBar from '@/components/FundingProgressBar';
-import { useWallet } from '@/components/WalletContext';
-import CampaignActions from '@/components/CampaignActions';
-import RevenueSharingPanel from '@/components/RevenueSharingPanel';
-import DonationModal from '@/components/DonationModal';
-import { Campaign, Vote, CATEGORY_LABELS, stroopsToXlm } from '@/types';
-import { parseContractError } from '@/utils/contractErrors';
-
-function formatDate(ts: number) {
- return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(ts * 1000));
-}
cancelCampaign,
} from "@/lib/contractClient";
-import { useTranslations, useLocale } from "next-intl";
-import { CauseDetailSkeleton } from "@/components/Skeleton";
import { Campaign, Vote, CATEGORY_LABELS } from "@/types";
import { stroopsToXlmNumber } from "@/lib/stellarAmount";
import { parseContractError } from "@/utils/contractErrors";
@@ -88,6 +46,14 @@ import { formatXlm, formatDate } from "@/lib/formatters";
import { getLocalizedDescription } from "@/utils/localizedDescription";
import { isBlankMarkdown } from "@/utils/markdownContent";
import { isSameAddress } from "@/lib/stellar";
+
+const RevenueSharingPanel = dynamic(() => import("@/components/RevenueSharingPanel"), {
+ ssr: false,
+});
+const VestingReservePanel = dynamic(() => import("@/components/VestingReservePanel"), {
+ ssr: false,
+});
+const DonationModal = dynamic(() => import("@/components/DonationModal"), { ssr: false });
const EditCampaignMetadata = dynamic(() => import("@/components/EditCampaignMetadata"), {
ssr: false,
});
@@ -363,12 +329,6 @@ export default function CauseDetailClient({ id }: { id: string }) {
- {campaign.title}
-
-
- {campaign.description}
-
-
{campaign.cover_image_url && (
- Write
+ {t("tabWrite")}
- Preview
+ {t("tabPreview")}
{descriptionTab === "write" ? (
@@ -547,7 +547,7 @@ export default function CreateCampaignPage() {
{description}
) : (
- Nothing to preview
+ {t("nothingToPreview")}
)}
)}
@@ -806,7 +806,7 @@ export default function CreateCampaignPage() {
htmlFor="revenueShareBps"
className="text-xs text-zinc-500 dark:text-zinc-400 shrink-0"
>
- Exact bps:
+ {t("labelExactBps")}
- / 10 000
+ {t("revenueShareBpsDenominator")}
{err("revenueSharePercentage") && (
@@ -890,7 +890,8 @@ export default function CreateCampaignPage() {
htmlFor="coverImageUrl"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
- Cover Image URL (optional)
+ {t("labelCoverImageUrl")}{" "}
+ {t("optional")}
{t("emptyTitle")}
-
- {t("emptyBody")}
-
+ {t("emptyBody")}
);
}
diff --git a/src/components/CancelDonationBanner.tsx b/src/components/CancelDonationBanner.tsx
index 900e9823..13b6d13c 100644
--- a/src/components/CancelDonationBanner.tsx
+++ b/src/components/CancelDonationBanner.tsx
@@ -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[];
@@ -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 (
-
- {campaign.description}
-
{/* Funding progress */}
@@ -293,12 +281,6 @@ function CauseCard({
);
}
-/**
- * Memoized so a list of cards does not re-render wholesale when unrelated
- * global state changes (#648). Cards are rendered in long lists, so this is
- * where an unnecessary render is most expensive.
- */
-export default memo(CauseCard);
function causeCardPropsAreEqual(prev: CauseCardProps, next: CauseCardProps): boolean {
const prevCampaign = prev.campaign;
const nextCampaign = next.campaign;
diff --git a/src/components/ContributorLeaderboard.tsx b/src/components/ContributorLeaderboard.tsx
index fa75cc53..1472fcb6 100644
--- a/src/components/ContributorLeaderboard.tsx
+++ b/src/components/ContributorLeaderboard.tsx
@@ -85,9 +85,7 @@ export default function ContributorLeaderboard({
{contributors.length === 0 ? (
-
- {t("emptyMessage")}
-
+
{t("emptyMessage")}
) : (
@@ -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/DonationModal.tsx b/src/components/DonationModal.tsx
index 819f22b9..e0b7e54a 100644
--- a/src/components/DonationModal.tsx
+++ b/src/components/DonationModal.tsx
@@ -11,14 +11,8 @@ import { useToast } from "./ToastProvider";
import { useWallet } from "./WalletContext";
import { usePlatformFee } from "../hooks/usePlatformFee";
import { parseContractError } from "../utils/contractErrors";
-import {
- INTERVAL_LABELS,
- RecurringInterval,
- createSchedule,
-} from "../lib/recurringDonations";
+import { INTERVAL_LABELS, RecurringInterval, createSchedule } from "../lib/recurringDonations";
-const EXPLORER_BASE =
- process.env.NEXT_PUBLIC_EXPLORER_URL ?? "https://stellar.expert/explorer/testnet/tx";
import { type TransactionLifecyclePhase } from "../lib/contractClient";
import { validateContributorNotCreator } from "../utils/validators";
import { explorerTxUrl } from "../utils/explorer";
@@ -259,8 +253,15 @@ export default function DonationModal({
trackReviewContribution(campaign.id);
try {
- const stroops = xlmToStroops(amountNum);
- const hash = await contribute(campaign.id, publicKey, stroops);
+ const stroops = xlmToStroops(amountToSend);
+ const hash = await contribute(campaign.id, publicKey, stroops, {
+ onStatus: ({ phase }) => {
+ setTxPhase(phase);
+ if (phase === "signing") {
+ trackSignTransaction(campaign.id);
+ }
+ },
+ });
// Only record the schedule once this cycle actually settled, so a failed
// donation never leaves a subscription behind.
@@ -274,15 +275,6 @@ export default function DonationModal({
});
}
- const stroops = xlmToStroops(amountToSend);
- const hash = await contribute(campaign.id, publicKey, stroops, {
- onStatus: ({ phase }) => {
- setTxPhase(phase);
- if (phase === "signing") {
- trackSignTransaction(campaign.id);
- }
- },
- });
setTxHash(hash);
setStep("confirmed");
trackContributionConfirmed(campaign.id);
@@ -529,8 +521,8 @@ export default function DonationModal({
{isRecurring && (
- {INTERVAL_LABELS[recurringInterval]} donation set up. We'll remind you when the
- next one is due.
+ {INTERVAL_LABELS[recurringInterval]} donation set up. We'll remind you when
+ the next one is due.
)}
{t("thankYou")}
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/DonorBadges.tsx b/src/components/DonorBadges.tsx
index af669d18..6707f534 100644
--- a/src/components/DonorBadges.tsx
+++ b/src/components/DonorBadges.tsx
@@ -26,7 +26,11 @@ function DonorBadges({ donations, variant = "full" }: DonorBadgesProps) {
{progress.current.icon} {progress.current.name}
{badges.map((badge) => (
-
+
{badge.icon}
))}
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/RecentActivityFeed.tsx b/src/components/RecentActivityFeed.tsx
index 64bd8429..4bf4c736 100644
--- a/src/components/RecentActivityFeed.tsx
+++ b/src/components/RecentActivityFeed.tsx
@@ -31,7 +31,7 @@ export default function RecentActivityFeed() {
.map((c) => ({
id: c.id,
label: `New cause: ${c.title}`,
- raised: c.amount_raised ?? c.raised_amount ?? BigInt(0),
+ raised: c.amount_raised ?? BigInt(0),
}));
useEffect(() => {
@@ -75,9 +75,7 @@ export default function RecentActivityFeed() {
aria-label={`Activity ${i + 1}`}
onClick={() => setActiveIndex(i)}
className={`w-1.5 h-1.5 rounded-full transition-colors ${
- i === activeIndex
- ? "bg-zinc-700 dark:bg-zinc-200"
- : "bg-zinc-300 dark:bg-zinc-600"
+ i === activeIndex ? "bg-zinc-700 dark:bg-zinc-200" : "bg-zinc-300 dark:bg-zinc-600"
}`}
/>
))}
diff --git a/src/components/ThirdPartyScripts.tsx b/src/components/ThirdPartyScripts.tsx
index ec5947b0..3ed025c9 100644
--- a/src/components/ThirdPartyScripts.tsx
+++ b/src/components/ThirdPartyScripts.tsx
@@ -38,34 +38,18 @@ export default function ThirdPartyScripts() {
return (
<>
- {scripts.map(({ id, src, strategy, attributes }) => {
- // #647 — Guard: `beforeInteractive` runs during SSR and blocks the main
- // thread before hydration — exactly the problem this component exists to
- // solve. Any misconfigured entry is demoted to `lazyOnload` at runtime
- // and flagged in the dev console so it can be fixed in thirdParty.ts.
- const safeStrategy =
- strategy === "beforeInteractive"
- ? (process.env.NODE_ENV !== "production" &&
- console.warn(
- `[ThirdPartyScripts] Script "${id}" uses "beforeInteractive" which blocks` +
- ` the main thread. Downgraded to "lazyOnload". Fix the strategy in thirdParty.ts.`
- ),
- "lazyOnload" as const)
- : strategy;
-
- return (
-
- );
- })}
+ {scripts.map(({ id, src, strategy, attributes }) => (
+
+ ))}
>
);
}
diff --git a/src/components/WalletContext.tsx b/src/components/WalletContext.tsx
index 5bde1dde..b38ddd64 100644
--- a/src/components/WalletContext.tsx
+++ b/src/components/WalletContext.tsx
@@ -1,17 +1,15 @@
"use client";
-import { getAddress, isConnected, isAllowed } from "@stellar/freighter-api";
+import * as StellarSdk from "@stellar/stellar-sdk";
+import { getAddress, getNetwork, isConnected, isAllowed } from "@stellar/freighter-api";
import React, {
createContext,
- useCallback,
useContext,
useEffect,
- useMemo,
useState,
+ useMemo,
ReactNode,
+ useRef,
} from "react";
-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 { useToast } from "./ToastProvider";
import { useQueryClient } from "@tanstack/react-query";
import { IS_MOCK_MODE } from "@/lib/runtimeEnv";
@@ -28,33 +26,9 @@ import {
import InstallFreighterModal from "./InstallFreighterModal";
import SocialLoginButtons from "./SocialLoginButtons";
-/**
- * Wallet state is split from wallet actions (#648).
- *
- * Previously one context held both, so every `isLoading` flip re-rendered each
- * consumer — including components that only ever call `connectWallet` and never
- * read state. Splitting means:
- *
- * - `useWalletState()` re-renders when publicKey / connected / loading change
- * - `useWalletActions()` never re-renders: the value is created once
- *
- * Both values are memoized, so a re-render of `WalletProvider` itself does not
- * cascade into consumers unless the data they read actually changed.
- */
-
-interface WalletState {
+interface WalletContextType {
publicKey: string | null;
isWalletConnected: boolean;
- isLoading: boolean;
-}
-
-interface WalletActions {
- connectWallet: () => Promise;
- disconnectWallet: () => void;
-}
-
-const WalletStateContext = createContext(undefined);
-const WalletActionsContext = createContext(undefined);
walletNetworkWarning: string | null;
connectWallet: () => Promise;
disconnectWallet: () => void;
@@ -94,7 +68,6 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
? "Testnet"
: "the app network";
- const checkWalletConnection = useCallback(async () => {
useEffect(() => {
if (IS_MOCK_MODE) {
const storedKey =
@@ -258,14 +231,9 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
} catch {
return false;
}
- }, []);
-
- useEffect(() => {
- // Always re-verify with Freighter rather than blindly trusting localStorage (#97)
- checkWalletConnection();
- }, [checkWalletConnection]);
+ };
- const connectWallet = useCallback(async () => {
+ const connectWallet = async () => {
setIsLoading(true);
try {
if (IS_MOCK_MODE) {
@@ -325,9 +293,8 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
} finally {
setIsLoading(false);
}
- }, [showError, showSuccess, showWarning]);
+ };
- const disconnectWallet = useCallback(() => {
/**
* #649 — Create or restore an embedded wallet from a Google or X account, so
* visitors without a browser extension can still contribute.
@@ -396,17 +363,7 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
showWarning(
"Disconnected. To fully revoke Freighter access, open the extension and remove this site from Connected Sites.",
);
- }, [showWarning]);
-
- const state = useMemo(
- () => ({ publicKey, isWalletConnected, isLoading }),
- [publicKey, isWalletConnected, isLoading]
- );
-
- const actions = useMemo(
- () => ({ connectWallet, disconnectWallet }),
- [connectWallet, disconnectWallet]
- );
+ };
const contextValue = useMemo(
() => ({
@@ -421,13 +378,18 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => {
isSocialLoginAvailable: isSocialLoginConfigured(),
connectWithSocial,
}),
- [publicKey, isWalletConnected, walletNetworkWarning, isLoading, walletKind, socialProfile, connectWithSocial]
+ [
+ publicKey,
+ isWalletConnected,
+ walletNetworkWarning,
+ isLoading,
+ walletKind,
+ socialProfile,
+ connectWithSocial,
+ ],
);
return (
-
- {children}
-
{children}
{
);
};
-/** Subscribe to wallet state only. Re-renders when connection state changes. */
-export const useWalletState = (): WalletState => {
- const ctx = useContext(WalletStateContext);
- if (!ctx) throw new Error("useWalletState must be used within a WalletProvider");
- return ctx;
-};
-
-/**
- * Subscribe to wallet actions only. Never causes a re-render — prefer this in
- * components that only trigger connect/disconnect (buttons, menu items).
- */
-export const useWalletActions = (): WalletActions => {
- const ctx = useContext(WalletActionsContext);
- if (!ctx) throw new Error("useWalletActions must be used within a WalletProvider");
- return ctx;
-};
-
-/**
- * Combined accessor, kept so existing call sites keep working. Subscribes to
- * both contexts — reach for `useWalletState` or `useWalletActions` instead when
- * a component only needs one half.
- */
export const useWallet = () => {
- const state = useWalletState();
- const actions = useWalletActions();
- return useMemo(() => ({ ...state, ...actions }), [state, actions]);
+ const ctx = useContext(WalletContext);
+ if (!ctx) throw new Error("useWallet must be used within a WalletProvider");
+ return ctx;
};
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/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/badges.ts b/src/lib/badges.ts
index 6abe1ffd..ca0844a9 100644
--- a/src/lib/badges.ts
+++ b/src/lib/badges.ts
@@ -127,7 +127,9 @@ export function earnedBadges(donations: DonationRecord[]): Badge[] {
const earned: BadgeId[] = ["first-donation"];
- if (donations.some((d) => typeof d.backerRank === "number" && d.backerRank <= EARLY_BACKER_RANK)) {
+ if (
+ donations.some((d) => typeof d.backerRank === "number" && d.backerRank <= EARLY_BACKER_RANK)
+ ) {
earned.push("early-backer");
}
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/recurringDonations.ts b/src/lib/recurringDonations.ts
index f2d2e697..6e5e2a92 100644
--- a/src/lib/recurringDonations.ts
+++ b/src/lib/recurringDonations.ts
@@ -49,7 +49,7 @@ export function addMonths(timestampMs: number, months: number): number {
result.setUTCMonth(result.getUTCMonth() + months);
const daysInTargetMonth = new Date(
- Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)
+ Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0),
).getUTCDate();
result.setUTCDate(Math.min(targetDay, daysInTargetMonth));
@@ -112,7 +112,7 @@ export function createSchedule(input: {
};
const all = readAll().filter(
- (s) => !(s.walletAddress === schedule.walletAddress && s.campaignId === schedule.campaignId)
+ (s) => !(s.walletAddress === schedule.walletAddress && s.campaignId === schedule.campaignId),
);
writeAll([...all, schedule]);
@@ -143,7 +143,7 @@ export function markCycleCompleted(id: string, now: number = Date.now()): void {
nextRunAt: nextRunAfter(now, s.interval),
cyclesCompleted: s.cyclesCompleted + 1,
}
- : s
- )
+ : s,
+ ),
);
}
diff --git a/src/middleware.ts b/src/middleware.ts
index 282e215b..02dff9a6 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -52,7 +52,7 @@ export default function middleware(req: NextRequest) {
if (process.env.NODE_ENV === "production") {
response.headers.set(
"Strict-Transport-Security",
- "max-age=63072000; includeSubDomains; preload"
+ "max-age=63072000; includeSubDomains; preload",
);
}
diff --git a/tests/e2e/withdrawal.spec.ts b/tests/e2e/withdrawal.spec.ts
index 4afa4d03..77d02969 100644
--- a/tests/e2e/withdrawal.spec.ts
+++ b/tests/e2e/withdrawal.spec.ts
@@ -20,22 +20,36 @@ 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/);
await expect(page.locator("body")).toBeVisible();
- // Step 2: Ensure dashboard elements load
- const dashboardHeader = page.getByRole("heading", { level: 1 }).or(page.locator("body"));
+ // Step 2: Ensure dashboard elements load.
+ // `.first()` because `.or()` yields every match from both sides - once the
+ // page renders an h1 alongside body, strict mode rejects the two-element
+ // result. Same for the button below.
+ const dashboardHeader = page
+ .getByRole("heading", { level: 1 })
+ .or(page.locator("body"))
+ .first();
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"))
+ .first();
await expect(withdrawBtn).toBeVisible();
// Step 4: Validate mock mode response and withdrawal UI readiness