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")}
{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 ( -