diff --git a/messages/fr.json b/messages/fr.json new file mode 100644 index 00000000..777a8e86 --- /dev/null +++ b/messages/fr.json @@ -0,0 +1,570 @@ +{ + "Common": { + "home": "Home", + "explore": "Explore Causes", + "about": "About", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "disconnect": "Disconnect", + "admin": "Admin", + "createCampaign": "Create Campaign", + "selectLanguage": "Select language", + "currentLanguage": "Current language: {language}", + "languageChanged": "Language changed to {language}", + "skipToMainContent": "Skip to main content", + "xlmPrecisionWarning": "Rounded to {amount} XLM", + "profile": "Profile", + "socialLoginDivider": "or", + "continueWithGoogle": "Continue with Google", + "continueWithX": "Continue with X", + "socialLoginHint": "Creates a Stellar wallet secured by your social account — no extension needed.", + "signedInAs": "Signed in as {name}" + }, + "Home": { + "heroTitle": "Decentralized validation for heart-driven causes.", + "heroSubtitle": "The community validates, you contribute, and impact is recorded on-chain.", + "startCampaign": "Start a Campaign", + "exploreCauses": "Explore Causes", + "statsRaised": "raised across all causes", + "statsCampaigns": "campaigns launched" + }, + "Dashboard": { + "title": "Your Dashboard", + "noWalletHeading": "Connect your wallet to view your dashboard", + "goHome": "Go Home", + "walletBalance": "Wallet Balance", + "loadingBalance": "Loading balance…", + "balanceFetchError": "Failed to fetch balance.", + "submittedCampaigns": "Your Submitted Campaigns", + "noSubmittedCampaigns": "You haven't submitted any campaigns yet.", + "votingHistory": "Voting History", + "noVotingHistory": "No voting activity yet.", + "upvotedOn": "Upvoted on {date}", + "downvotedOn": "Downvoted on {date}", + "viewOnExplorer": "View on Explorer", + "fundingHistory": "Funding/Donation History", + "noFundingHistory": "No funding/donation activity yet.", + "donated": "Donated {amount} XLM on {date}", + "campaignFallback": "Campaign #{id}", + "withdrawalsTab": "Withdrawals", + "withdrawalsHeading": "Multi-Signature Withdrawals", + "withdrawalsDescription": "Campaigns you run. Once a campaign reaches its goal you can propose a withdrawal and collect approvals from your organization's signers before the funds move." + }, + "MyContributions": { + "title": "My Contributions", + "summary": "{count, plural, one {# campaign contributed} other {# campaigns contributed}} · {total} XLM total", + "loading": "Loading contribution history...", + "empty": "No contributions found for this wallet yet.", + "contributed": "Contributed {amount} XLM", + "statusActive": "Active", + "statusRefundable": "Refundable", + "statusRevenueClaimable": "Revenue claimable", + "openCampaign": "Open Campaign", + "claimRefund": "Claim Refund", + "claimRevenue": "Claim Revenue ({amount} XLM)", + "signing": "Signing...", + "confirming": "Confirming...", + "claimingRefund": "Claiming...", + "claimingRevenue": "Claiming...", + "contributionTx": "Contribution tx:", + "noContributionTx": "No contribution transaction recorded on this device yet.", + "transactionLog": "Transaction log", + "refreshing": "Refreshing contribution history...", + "refundSuccess": "Refund claimed successfully.", + "revenueSuccess": "Revenue claimed successfully.", + "actionContribute": "Contribution", + "actionClaimRefund": "Refund claim", + "actionClaimRevenue": "Revenue claim", + "actionDepositRevenue": "Revenue deposit", + "actionVote": "Vote", + "actionTransaction": "Transaction" + }, + "Donation": { + "title": "Fund This Cause", + "confirmedTitle": "Donation Confirmed", + "closeAriaLabel": "Close", + "amountLabel": "Amount (XLM)", + "amountPlaceholder": "e.g. 10", + "percentFunded": "{percent}% funded", + "afterDonation": "After your donation: {percent}% funded", + "goalReached": "Goal reached!", + "contributionLine": "Contribution", + "networkFeeLine": "Est. network fee", + "totalLine": "Total from your wallet", + "donate": "Donate", + "donateAmount": "Donate {amount} XLM", + "platformFeeNote": "A platform fee of {feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.", + "networkFeeNote": "The estimated network fee covers Stellar ledger costs for this transaction. It is separate from the platform fee.", + "waitingSignature": "Waiting for Freighter signature…", + "waitingConfirmation": "Waiting for ledger confirmation…", + "submitting": "Submitting transaction to the network…", + "donatedSuccess": "{amount} XLM donated successfully", + "thankYou": "Thank you for supporting this cause.", + "viewExplorer": "View on Stellar Explorer →", + "close": "Close" + }, + "Admin": { + "title": "Admin Moderation", + "subtitle": "Review and validate new causes submitted to the platform.", + "cause": "Cause", + "creator": "Creator", + "goal": "Goal", + "category": "Category", + "actions": "Actions", + "approve": "Approve Cause", + "reject": "Reject Cause", + "noPending": "No pending causes found. Great job!", + "totalCampaigns": "Total Campaigns", + "totalRaised": "Total Raised", + "activeCampaigns": "Active Campaigns", + "platformFee": "Platform Fee", + "verificationQueue": "Verification Queue", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "reviewDetails": "Review Details", + "updatePlatformFee": "Update Platform Fee", + "feeInBasisPoints": "Fee in basis points", + "awaitingSignature": "Awaiting signature...", + "transferAdmin": "Transfer Admin", + "newAdminAddress": "New Admin Address", + "adminConsole": "Admin Console", + "contractAdmin": "Contract Admin", + "responsibility": "Admin Responsibility", + "responsibilityText": "As an administrator, you are responsible for ensuring all causes meet our community guidelines. Verification updates the status on-chain, signaling trust to potential contributors. Rejection will cancel the campaign and prevent further contributions.", + "confirmTransferTitle": "Transfer Admin Access?", + "confirmTransferBody": "This action is irreversible. You will lose all administrative powers. Please verify the new admin address carefully.", + "typeConfirm": "Type CONFIRM to proceed", + "confirmLabel": "Confirmation", + "cancel": "Cancel", + "confirmTransfer": "Confirm Transfer", + "walletRequired": "Wallet Required", + "walletRequiredDesc": "Please connect your administrative wallet to access the platform controls.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "unauthorized": "Unauthorized Access", + "unauthorizedDesc": "Your wallet {address} is not registered as the administrator.", + "returnHome": "Return Home", + "observabilityLink": "RPC & transaction metrics", + "feeDescription": "Configure the percentage taken from successful campaigns to support the platform.", + "feeWarning": "Warning: Fee exceeds 10% (1000 bps)", + "transferAdminDesc": "Irreversible action. Move all administrative powers to a new address.", + "recentActivity": "Recent Admin Activity", + "recentActivityDesc": "Last 50 moderation and governance actions performed by this admin wallet.", + "noActivity": "No admin activity recorded on this device yet.", + "abuseReports": "Abuse Reports", + "pendingReports": "{count} pending", + "noReports": "No reports yet", + "reportBy": "By {address}", + "anonymous": "Anonymous", + "view": "View", + "pending": "Pending", + "reviewed": "Reviewed", + "approveSuccess": "Campaign approved successfully!", + "rejectSuccess": "Campaign rejected and cancelled.", + "reportReviewedSuccess": "Report marked as reviewed.", + "reportReviewError": "Failed to update report.", + "feeUpdateSuccess": "Platform fee updated", + "adminOnlyFee": "Only admin can update fee", + "invalidFee": "Invalid fee", + "adminOnlyTransfer": "Only admin can transfer", + "invalidAddress": "Invalid address", + "adminTransferSuccess": "Admin access transferred", + "confirmHighFeeTitle": "Confirm High Fee Update", + "confirmHighFeeBody": "Fees above 10% require extra confirmation before being applied. Type CONFIRM to continue.", + "confirmFeeContinue": "Type CONFIRM to continue", + "updateFee": "Update Fee", + "preparing": "Preparing...", + "signing": "Signing...", + "submitting": "Submitting...", + "confirming": "Confirming...", + "transferring": "Transferring...", + "requiredWord": "CONFIRM" + }, + "ContractErrors": { + "NotAuthorized": "You are not authorized to perform this action.", + "CampaignNotFound": "This campaign could not be found.", + "CampaignNotActive": "This campaign is no longer accepting contributions.", + "FundingGoalMustBePositive": "The funding goal must be greater than zero.", + "InvalidDuration": "The campaign duration is invalid.", + "InvalidRevenueShare": "The revenue share percentage is invalid.", + "RevenueShareOnlyForStartup": "Revenue sharing is only available for startup campaigns.", + "DeadlinePassed": "The campaign deadline has passed.", + "ContributionMustBePositive": "Please enter a valid contribution amount.", + "DeadlineNotPassed": "The campaign deadline has not passed yet.", + "FundsAlreadyWithdrawn": "The funds for this campaign have already been withdrawn.", + "FundingGoalNotReached": "The funding goal has not been reached yet.", + "NoFundsToWithdraw": "There are no funds available to withdraw.", + "CampaignAlreadyVerified": "This campaign has already been verified.", + "ValidationFailed": "Validation failed. Please check your input and try again.", + "AlreadyVoted": "You have already voted on this campaign.", + "NotTokenHolder": "You must hold the platform token to vote.", + "VotingQuorumNotMet": "Not enough votes have been cast yet.", + "VotingThresholdNotMet": "The approval threshold has not been reached.", + "UnexpectedError": "An unexpected error occurred. Please try again." + }, + "About": { + "pageTitle": "About ProofOfHeart", + "pageSubtitle": "A decentralized launchpad where the community validates causes through transparent, on-chain participation.", + "whatIsTitle": "What is ProofOfHeart?", + "whatIsBody": "ProofOfHeart is an open platform built on the Stellar blockchain that lets anyone submit a cause and have it validated by the community. Every vote, donation, and outcome is recorded on-chain — giving contributors full transparency into how funds are raised and used.", + "howItWorksTitle": "How it works", + "step1Title": "Submit a cause", + "step1Body": "Describe your cause, set a funding goal, and submit it for community review.", + "step2Title": "Community validates", + "step2Body": "Wallet holders vote to approve or reject the cause. Verified causes move to active fundraising.", + "step3Title": "Contribute & track", + "step3Body": "Donate to causes you believe in and track every transaction on the Stellar explorer.", + "step4Title": "On-chain outcomes", + "step4Body": "Funded causes are marked on-chain. Unfunded causes trigger automatic refunds to contributors.", + "whoCanTitle": "Who can participate?", + "whoCanBody": "Anyone with a Stellar wallet can vote on causes and contribute funds. Cause creators connect their wallet and fill out a short form to submit their proposal.", + "faqTitle": "Frequently asked questions", + "faq1Q": "Is ProofOfHeart open source?", + "faq1A": "Yes. The smart contracts and frontend are publicly available on GitHub.", + "faq2Q": "Which wallet do I need?", + "faq2A": "Any Stellar-compatible wallet works, including Freighter and LOBSTR.", + "faq3Q": "Are donations refundable?", + "faq3A": "If a cause does not reach its funding goal, the smart contract automatically returns contributions to donors.", + "faq4Q": "How are causes moderated?", + "faq4A": "Platform administrators review submissions before they go live. The community then votes to signal broader trust." + }, + "Causes": { + "pageTitle": "Community Causes", + "pageSubtitle": "Browse, search, and vote on causes that matter to you.", + "searchPlaceholder": "Search causes by title, description, or category…", + "labelCategory": "Category", + "labelStatus": "Status", + "labelSortBy": "Sort by", + "all": "All", + "allCategories": "All Categories", + "categoryChipAriaSelected": "{label}, {count} causes, selected", + "categoryChipAriaUnselected": "{label}, {count} causes", + "allStatuses": "All Statuses", + "clearFilters": "Clear filters", + "clearAllFilters": "Clear all filters", + "causesFound_one": "{count} cause found", + "causesFound_other": "{count} causes found", + "causesFoundFor": " for \"{query}\"", + "processingVote": "Processing vote…", + "noCausesYet": "No causes yet", + "noCausesFound": "No causes found", + "noSearchResults": "No search results", + "beFirstToSubmit": "Be the first to submit a cause for the community to vote on.", + "tryDifferentKeyword": "Try a different keyword or clear the filters.", + "tryDifferentSearch": "Try different search terms or clear the filters to see all causes.", + "tryAgain": "Try again", + "loadMore": "Load more causes", + "showingRange": "Showing {shown} of {total}", + "listView": "List", + "mapView": "Map" + }, + "Status": { + "active": "Active", + "cancelled": "Cancelled", + "funded": "Funded", + "failed": "Failed", + "verified": "Verified", + "verifiedCampaign": "Verified campaign" + }, + "CreateCampaign": { + "pageTitle": "Create a Campaign", + "pageSubtitle": "Fill in the details below to launch your campaign on-chain via Freighter.", + "walletGuard": "Connect your Freighter wallet to create a campaign.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting…", + "labelTitle": "Campaign Title", + "placeholderTitle": "A clear, compelling title for your campaign", + "labelDescription": "Description", + "placeholderDescription": "Describe your campaign, what it aims to achieve, and how funds will be used", + "labelCreatorEmailOptional": "Creator Email (Optional)", + "placeholderCreatorEmail": "name@example.com", + "creatorEmailNote": "Optional, off-chain only. Used for campaign milestone and deadline notifications.", + "labelFundingGoal": "Funding Goal (XLM)", + "labelDuration": "Duration (days)", + "placeholderDuration": "1–365", + "labelCategory": "Category", + "startupRevenueHint": "Revenue sharing is available for Educational Startup campaigns.", + "revenueSharingTitle": "Revenue Sharing", + "revenueSharingDesc": "Allow contributors to earn a share of future revenue.", + "revenueSharingAriaLabel": "Revenue Sharing", + "labelRevenueSharePct": "Revenue Share Percentage", + "cancel": "Cancel", + "launchCampaign": "Launch Campaign", + "reviewTitle": "Review Campaign Before Signing", + "reviewSubtitle": "Launching is irreversible on-chain. Confirm these details before signing in Freighter.", + "reviewFieldTitle": "Title", + "reviewFieldCreatorEmail": "Creator Email", + "reviewFieldFundingGoal": "Funding Goal", + "reviewFieldDuration": "Duration", + "reviewFieldDurationDays_one": "{count} day", + "reviewFieldDurationDays_other": "{count} days", + "reviewFieldCategory": "Category", + "reviewFieldRevenueShare": "Revenue Share", + "reviewRevenueShareNone": "0.00% (Not enabled)", + "reviewCreatorEmailNone": "No email provided", + "reviewFieldEndDate": "End Date (Your Timezone)", + "reviewFieldTimestamp": "Estimated Deadline Timestamp", + "editDetails": "Edit Details", + "confirmAndSign": "Confirm & Sign", + "submitting": "Submitting…", + "successMessage": "Campaign \"{title}\" created successfully!", + "emailWebhookFailed": "Campaign created, but we could not save your optional email subscription.", + "walletRequiredError": "Please connect your Freighter wallet before creating a campaign.", + "validationTitleRequired": "Title is required.", + "validationTitleTooLong": "Title must be 100 characters or fewer.", + "validationDescriptionRequired": "Description is required.", + "validationDescriptionTooLong": "Description must be 1,000 characters or fewer.", + "validationCreatorEmailInvalid": "Enter a valid email address or leave this field empty.", + "validationFundingGoalInvalid": "Funding goal must be greater than 0 XLM.", + "validationDurationInvalid": "Duration must be between 1 and 365 days.", + "validationRevenueShareInvalid": "Revenue share must be between 0.01% and 50%.", + "labelDescriptionLanguage": "Description Language", + "tabEnglish": "English", + "tabSpanish": "Spanish (optional)", + "placeholderDescriptionEs": "Describe tu campaña en español (opcional)", + "validationDescriptionEsTooLong": "Spanish description must be 1,000 characters or fewer." + }, + "Footer": { + "tagline": "A decentralized launchpad where the community validates causes and contributions are accounted for on-chain.", + "productHeading": "Product", + "linksHeading": "Links", + "home": "Home", + "exploreCauses": "Explore Causes", + "about": "About", + "github": "GitHub", + "stellar": "Stellar", + "rights": "© {year} ProofOfHeart. All rights reserved.", + "builtWith": "Built with Next.js + Stellar" + }, + "Explore": { + "title": "Explore", + "subtitle": "Browse causes, see community validation, and support what matters.", + "all": "All", + "noCausesYet": "No causes yet", + "noCausesInCategory": "No causes in this category", + "checkBackSoon": "Check back soon — the community is just getting started.", + "tryDifferentCategory": "Try selecting a different category above.", + "tryAgain": "Try again" + }, + "DonationModal": { + "titleConfirmed": "Donation Confirmed", + "titleFund": "Fund This Cause", + "closeAriaLabel": "Close", + "percentFunded": "{percent}% funded", + "amountLabel": "Amount (XLM)", + "amountPlaceholder": "e.g. 10", + "afterDonation": "After your donation: {percent}% funded", + "goalReached": "🎉 Goal reached!", + "platformFeeNote": "A platform fee of {feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.", + "donate": "Donate", + "donateWithAmount": "Donate {amount} XLM", + "waitingForSignature": "Waiting for Freighter signature…", + "waitingForConfirmation": "Waiting for ledger confirmation…", + "submittingTransaction": "Submitting transaction to the network…", + "donatedSuccess": "{amount} XLM donated successfully", + "thankYou": "Thank you for supporting this cause.", + "viewOnExplorer": "View on Stellar Explorer →", + "close": "Close", + "scientificNotation": "Scientific notation is not allowed.", + "invalidNumber": "Please enter a valid number.", + "invalidAmount": "Please enter a valid amount.", + "amountMustBePositive": "Amount must be greater than zero.", + "invalidNumberFormat": "Invalid number format.", + "maxDecimalPlaces": "Maximum 7 decimal places allowed.", + "amountExceedsRemainingGoal": "Amount exceeds the remaining funding goal.", + "campaignAlreadyFunded": "This cause is already fully funded." + }, + "WithdrawFunds": { + "withdrawalSuccessToast": "Withdrawal successful! You received {amount} XLM.", + "withdrawalSuccessful": "Withdrawal Successful", + "amountReceived": "Amount received:", + "platformFeeLabel": "Platform fee:", + "transaction": "Transaction:", + "viewOnExplorer": "View on Explorer", + "title": "Withdraw Funds", + "totalRaised": "Total raised", + "platformFeeWithPercent": "Platform fee ({percent}%)", + "youWillReceive": "You will receive", + "confirmMessage": "Are you sure you want to withdraw? This action cannot be undone. You will withdraw {total} XLM total, pay {fee} XLM in platform fees, and receive {net} XLM.", + "signing": "Signing…", + "confirming": "Confirming…", + "processing": "Processing…", + "confirmWithdrawal": "Confirm Withdrawal", + "cancel": "Cancel", + "withdrawFunds": "Withdraw Funds", + "disabledCancelled": "Campaign has been cancelled", + "disabledAlreadyWithdrawn": "Funds have already been withdrawn", + "disabledGoalNotReached": "Funding goal has not been reached", + "disabledStillActive": "Campaign is still active" + }, + "MultiSigPanel": { + "title": "Multi-Signature Withdrawal", + "description": "Require multiple organization members to approve before withdrawing funds. Set up co-signers and a signature threshold.", + "setupProposal": "Set Up Multi-Sig Withdrawal", + "signersLabel": "Co-signer Wallet Addresses", + "signerPlaceholder": "G… Stellar address", + "removeSigner": "Remove signer", + "addSigner": "Add another signer", + "thresholdLabel": "Required signatures", + "thresholdOf": "of {total} signers", + "createProposal": "Create Proposal", + "cancel": "Cancel", + "cancelProposal": "Cancel Proposal", + "proposalCreated": "Withdrawal proposal created. Share it with your co-signers.", + "signaturesProgress": "{signed} of {required} signatures collected", + "statusPending": "Pending", + "statusReady": "Ready to Execute", + "signProposal": "Sign Proposal", + "signedSuccess": "You signed the withdrawal proposal.", + "executeWithdrawal": "Execute Withdrawal ({amount} XLM)", + "executedTitle": "Multi-sig withdrawal executed successfully.", + "viewOnExplorer": "View on Explorer", + "signing": "Signing…", + "confirming": "Confirming…", + "processing": "Processing…", + "withdrawalSuccess": "Withdrawal successful! You received {amount} XLM.", + "errorNoSigners": "Please add at least one co-signer address.", + "errorInvalidThreshold": "Required signatures must be between 1 and the number of co-signers." + }, + "Voting": { + "title": "Vote on this cause", + "connectWalletToVote": "Please connect your wallet to vote.", + "mustHoldTokens": "You must hold governance tokens to vote.", + "alreadyVotedWarning": "You have already voted on this cause.", + "approveCampaignAria": "Approve campaign", + "rejectCampaignAria": "Reject campaign", + "connectWalletTitle": "Connect your wallet to vote", + "tokenHoldersOnly": "Token holders only", + "alreadyVotedTitle": "Already voted", + "approve": "Approve", + "reject": "Reject", + "processingVote": "Processing vote...", + "netVotes": "Net votes ({total} total)", + "approveRejectPercent": "{approve}% Approve / {reject}% Reject", + "approveCount": "{count} Approve", + "rejectCount": "{count} Reject", + "quorumProgress": "Quorum progress", + "votesOfQuorum": "{current} of {quorum} votes", + "approvalThreshold": "Approval threshold", + "approvalOfThreshold": "{current}% of {threshold}%", + "needMoreVotes": "Need {count} more votes to reach quorum.", + "needMoreVotes_one": "Need {count} more vote to reach quorum.", + "needMoreVotes_other": "Need {count} more votes to reach quorum.", + "needMoreApproval": "Need {count} more approval points to reach the approval threshold.", + "needMoreApproval_one": "Need {count} more approval point to reach the approval threshold.", + "needMoreApproval_other": "Need {count} more approval points to reach the approval threshold.", + "quorumReached": "Quorum and approval threshold reached. Anyone can finalize verification.", + "connectWalletPrompt": "Connect your wallet to vote on this cause", + "tokenHoldersOnlyPrompt": "Only governance token holders can vote on causes", + "votedUpvote": "You voted to approve this cause", + "votedDownvote": "You voted to reject this cause", + "verifyWithVotes": "✓ Verify Campaign with Votes", + "verifying": "Verifying..." + }, + "Observability": { + "loading": "Loading observability metrics…", + "metricsRequestFailed": "Metrics request failed ({status})", + "loadError": "Failed to load metrics", + "healthTitle": "RPC & transaction health", + "healthSubtitle": "Rolling window: {minutes} min · Updated {time}", + "refresh": "Refresh", + "simulationFailures": "Simulation failures", + "submissionFailures": "Submission failures", + "rpcTimeouts": "RPC timeouts", + "contractErrors": "Contract errors", + "failuresByKind": "Failures by kind", + "noEventsInWindow": "No events in the current window.", + "contractErrorCodes": "Contract error codes", + "noContractErrors": "No contract errors in the current window.", + "recentEvents": "Recent events", + "noRecentEvents": "No recent events recorded yet." + }, + "CauseJsonLd": { + "donateActionName": "Support {title}", + "fundingGoalLabel": "Funding Goal", + "amountRaisedLabel": "Amount Raised", + "deadlineLabel": "Funding Deadline" + }, + "Profile": { + "noWallet": "Connect your wallet to view your profile.", + "noWalletSub": "Your impact, contributions, and saved campaigns will appear here.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "walletAddress": "Wallet address", + "totalContributed": "Total Contributed", + "campaignsBacked": "Campaigns Backed", + "campaignsCreated": "Campaigns Created", + "savedCampaigns": "Saved Campaigns", + "createdCampaigns": "Created Campaigns", + "noSavedCampaigns": "No saved campaigns yet.", + "browseCauses": "Browse causes" + }, + "Onboarding": { + "tourLabel": "Onboarding Tour", + "skip": "Skip", + "back": "Back", + "next": "Next", + "done": "Done", + "stepProgress": "Step {current} of {total}", + "step_connect_icon": "👛", + "step_connect_title": "Connect Wallet", + "step_connect_body": "Connect your Stellar wallet to get started.", + "step_chooseCause_icon": "❤️", + "step_chooseCause_title": "Choose a Cause", + "step_chooseCause_body": "Browse and select a cause you want to support.", + "step_contribute_icon": "💸", + "step_contribute_title": "Contribute", + "step_contribute_body": "Make a donation to help the cause reach its funding goal.", + "step_confirm_icon": "🔗", + "step_confirm_title": "Track Impact", + "step_confirm_body": "Your contribution is recorded on the Stellar blockchain." + }, + "CampaignMap": { + "emptyTitle": "No campaigns with location data", + "emptyBody": "Campaigns with coordinates will appear on this map. Switch to list view to browse all campaigns." + }, + "Gamification": { + "level_Bronze": "Bronze", + "level_Silver": "Silver", + "level_Gold": "Gold", + "level_Platinum": "Platinum", + "level_Diamond": "Diamond", + "badge_early_backer_name": "Early Backer", + "badge_early_backer_desc": "Supported ProofOfHeart in its early days", + "badge_streak_master_name": "Streak Master", + "badge_streak_master_desc": "Made 3 or more donations", + "badge_whale_name": "Whale", + "badge_whale_desc": "Donated 1,000 XLM or more in total", + "badge_heart_champion_name": "Heart Champion", + "badge_heart_champion_desc": "Reached Diamond level (5,000+ XLM donated)", + "locked": "Locked" + }, + "ContributorLeaderboard": { + "title": "Top Supporters", + "subtitle": "Leaderboard", + "emptyMessage": "Be the first supporter for this cause! 💜", + "youBadge": "You", + "hideWallet": "Hide my wallet address", + "anonymousState": "✓ Anonymous", + "optOutState": "Opt out", + "optOutTooltip": "Contributors can opt out at any time to remain anonymous on public leaderboards.", + "disableAnon": "Disable anonymous mode", + "enableAnon": "Enable anonymous mode" + }, + "DonatorBadges": { + "level": "Level {levelNumber}: {levelName}", + "totalXlm": "{amount} XLM Total", + "nextXlm": "Next: {amount} XLM", + "earnedBadges": "Earned Badges" + }, + "CauseDetail": { + "noDescription": "No description provided by the creator." + }, + "FiatOnramp": { + "buyWithCard": "Buy XLM with card", + "buyWithCardDescription": "New to crypto? Purchase XLM with a card or bank transfer, then donate.", + "opening": "Opening secure checkout…", + "error": "Could not open the payment provider. Please try again.", + "poweredBy": "Powered by {provider}" + } +} diff --git a/messages/pt.json b/messages/pt.json new file mode 100644 index 00000000..777a8e86 --- /dev/null +++ b/messages/pt.json @@ -0,0 +1,570 @@ +{ + "Common": { + "home": "Home", + "explore": "Explore Causes", + "about": "About", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "disconnect": "Disconnect", + "admin": "Admin", + "createCampaign": "Create Campaign", + "selectLanguage": "Select language", + "currentLanguage": "Current language: {language}", + "languageChanged": "Language changed to {language}", + "skipToMainContent": "Skip to main content", + "xlmPrecisionWarning": "Rounded to {amount} XLM", + "profile": "Profile", + "socialLoginDivider": "or", + "continueWithGoogle": "Continue with Google", + "continueWithX": "Continue with X", + "socialLoginHint": "Creates a Stellar wallet secured by your social account — no extension needed.", + "signedInAs": "Signed in as {name}" + }, + "Home": { + "heroTitle": "Decentralized validation for heart-driven causes.", + "heroSubtitle": "The community validates, you contribute, and impact is recorded on-chain.", + "startCampaign": "Start a Campaign", + "exploreCauses": "Explore Causes", + "statsRaised": "raised across all causes", + "statsCampaigns": "campaigns launched" + }, + "Dashboard": { + "title": "Your Dashboard", + "noWalletHeading": "Connect your wallet to view your dashboard", + "goHome": "Go Home", + "walletBalance": "Wallet Balance", + "loadingBalance": "Loading balance…", + "balanceFetchError": "Failed to fetch balance.", + "submittedCampaigns": "Your Submitted Campaigns", + "noSubmittedCampaigns": "You haven't submitted any campaigns yet.", + "votingHistory": "Voting History", + "noVotingHistory": "No voting activity yet.", + "upvotedOn": "Upvoted on {date}", + "downvotedOn": "Downvoted on {date}", + "viewOnExplorer": "View on Explorer", + "fundingHistory": "Funding/Donation History", + "noFundingHistory": "No funding/donation activity yet.", + "donated": "Donated {amount} XLM on {date}", + "campaignFallback": "Campaign #{id}", + "withdrawalsTab": "Withdrawals", + "withdrawalsHeading": "Multi-Signature Withdrawals", + "withdrawalsDescription": "Campaigns you run. Once a campaign reaches its goal you can propose a withdrawal and collect approvals from your organization's signers before the funds move." + }, + "MyContributions": { + "title": "My Contributions", + "summary": "{count, plural, one {# campaign contributed} other {# campaigns contributed}} · {total} XLM total", + "loading": "Loading contribution history...", + "empty": "No contributions found for this wallet yet.", + "contributed": "Contributed {amount} XLM", + "statusActive": "Active", + "statusRefundable": "Refundable", + "statusRevenueClaimable": "Revenue claimable", + "openCampaign": "Open Campaign", + "claimRefund": "Claim Refund", + "claimRevenue": "Claim Revenue ({amount} XLM)", + "signing": "Signing...", + "confirming": "Confirming...", + "claimingRefund": "Claiming...", + "claimingRevenue": "Claiming...", + "contributionTx": "Contribution tx:", + "noContributionTx": "No contribution transaction recorded on this device yet.", + "transactionLog": "Transaction log", + "refreshing": "Refreshing contribution history...", + "refundSuccess": "Refund claimed successfully.", + "revenueSuccess": "Revenue claimed successfully.", + "actionContribute": "Contribution", + "actionClaimRefund": "Refund claim", + "actionClaimRevenue": "Revenue claim", + "actionDepositRevenue": "Revenue deposit", + "actionVote": "Vote", + "actionTransaction": "Transaction" + }, + "Donation": { + "title": "Fund This Cause", + "confirmedTitle": "Donation Confirmed", + "closeAriaLabel": "Close", + "amountLabel": "Amount (XLM)", + "amountPlaceholder": "e.g. 10", + "percentFunded": "{percent}% funded", + "afterDonation": "After your donation: {percent}% funded", + "goalReached": "Goal reached!", + "contributionLine": "Contribution", + "networkFeeLine": "Est. network fee", + "totalLine": "Total from your wallet", + "donate": "Donate", + "donateAmount": "Donate {amount} XLM", + "platformFeeNote": "A platform fee of {feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.", + "networkFeeNote": "The estimated network fee covers Stellar ledger costs for this transaction. It is separate from the platform fee.", + "waitingSignature": "Waiting for Freighter signature…", + "waitingConfirmation": "Waiting for ledger confirmation…", + "submitting": "Submitting transaction to the network…", + "donatedSuccess": "{amount} XLM donated successfully", + "thankYou": "Thank you for supporting this cause.", + "viewExplorer": "View on Stellar Explorer →", + "close": "Close" + }, + "Admin": { + "title": "Admin Moderation", + "subtitle": "Review and validate new causes submitted to the platform.", + "cause": "Cause", + "creator": "Creator", + "goal": "Goal", + "category": "Category", + "actions": "Actions", + "approve": "Approve Cause", + "reject": "Reject Cause", + "noPending": "No pending causes found. Great job!", + "totalCampaigns": "Total Campaigns", + "totalRaised": "Total Raised", + "activeCampaigns": "Active Campaigns", + "platformFee": "Platform Fee", + "verificationQueue": "Verification Queue", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "reviewDetails": "Review Details", + "updatePlatformFee": "Update Platform Fee", + "feeInBasisPoints": "Fee in basis points", + "awaitingSignature": "Awaiting signature...", + "transferAdmin": "Transfer Admin", + "newAdminAddress": "New Admin Address", + "adminConsole": "Admin Console", + "contractAdmin": "Contract Admin", + "responsibility": "Admin Responsibility", + "responsibilityText": "As an administrator, you are responsible for ensuring all causes meet our community guidelines. Verification updates the status on-chain, signaling trust to potential contributors. Rejection will cancel the campaign and prevent further contributions.", + "confirmTransferTitle": "Transfer Admin Access?", + "confirmTransferBody": "This action is irreversible. You will lose all administrative powers. Please verify the new admin address carefully.", + "typeConfirm": "Type CONFIRM to proceed", + "confirmLabel": "Confirmation", + "cancel": "Cancel", + "confirmTransfer": "Confirm Transfer", + "walletRequired": "Wallet Required", + "walletRequiredDesc": "Please connect your administrative wallet to access the platform controls.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "unauthorized": "Unauthorized Access", + "unauthorizedDesc": "Your wallet {address} is not registered as the administrator.", + "returnHome": "Return Home", + "observabilityLink": "RPC & transaction metrics", + "feeDescription": "Configure the percentage taken from successful campaigns to support the platform.", + "feeWarning": "Warning: Fee exceeds 10% (1000 bps)", + "transferAdminDesc": "Irreversible action. Move all administrative powers to a new address.", + "recentActivity": "Recent Admin Activity", + "recentActivityDesc": "Last 50 moderation and governance actions performed by this admin wallet.", + "noActivity": "No admin activity recorded on this device yet.", + "abuseReports": "Abuse Reports", + "pendingReports": "{count} pending", + "noReports": "No reports yet", + "reportBy": "By {address}", + "anonymous": "Anonymous", + "view": "View", + "pending": "Pending", + "reviewed": "Reviewed", + "approveSuccess": "Campaign approved successfully!", + "rejectSuccess": "Campaign rejected and cancelled.", + "reportReviewedSuccess": "Report marked as reviewed.", + "reportReviewError": "Failed to update report.", + "feeUpdateSuccess": "Platform fee updated", + "adminOnlyFee": "Only admin can update fee", + "invalidFee": "Invalid fee", + "adminOnlyTransfer": "Only admin can transfer", + "invalidAddress": "Invalid address", + "adminTransferSuccess": "Admin access transferred", + "confirmHighFeeTitle": "Confirm High Fee Update", + "confirmHighFeeBody": "Fees above 10% require extra confirmation before being applied. Type CONFIRM to continue.", + "confirmFeeContinue": "Type CONFIRM to continue", + "updateFee": "Update Fee", + "preparing": "Preparing...", + "signing": "Signing...", + "submitting": "Submitting...", + "confirming": "Confirming...", + "transferring": "Transferring...", + "requiredWord": "CONFIRM" + }, + "ContractErrors": { + "NotAuthorized": "You are not authorized to perform this action.", + "CampaignNotFound": "This campaign could not be found.", + "CampaignNotActive": "This campaign is no longer accepting contributions.", + "FundingGoalMustBePositive": "The funding goal must be greater than zero.", + "InvalidDuration": "The campaign duration is invalid.", + "InvalidRevenueShare": "The revenue share percentage is invalid.", + "RevenueShareOnlyForStartup": "Revenue sharing is only available for startup campaigns.", + "DeadlinePassed": "The campaign deadline has passed.", + "ContributionMustBePositive": "Please enter a valid contribution amount.", + "DeadlineNotPassed": "The campaign deadline has not passed yet.", + "FundsAlreadyWithdrawn": "The funds for this campaign have already been withdrawn.", + "FundingGoalNotReached": "The funding goal has not been reached yet.", + "NoFundsToWithdraw": "There are no funds available to withdraw.", + "CampaignAlreadyVerified": "This campaign has already been verified.", + "ValidationFailed": "Validation failed. Please check your input and try again.", + "AlreadyVoted": "You have already voted on this campaign.", + "NotTokenHolder": "You must hold the platform token to vote.", + "VotingQuorumNotMet": "Not enough votes have been cast yet.", + "VotingThresholdNotMet": "The approval threshold has not been reached.", + "UnexpectedError": "An unexpected error occurred. Please try again." + }, + "About": { + "pageTitle": "About ProofOfHeart", + "pageSubtitle": "A decentralized launchpad where the community validates causes through transparent, on-chain participation.", + "whatIsTitle": "What is ProofOfHeart?", + "whatIsBody": "ProofOfHeart is an open platform built on the Stellar blockchain that lets anyone submit a cause and have it validated by the community. Every vote, donation, and outcome is recorded on-chain — giving contributors full transparency into how funds are raised and used.", + "howItWorksTitle": "How it works", + "step1Title": "Submit a cause", + "step1Body": "Describe your cause, set a funding goal, and submit it for community review.", + "step2Title": "Community validates", + "step2Body": "Wallet holders vote to approve or reject the cause. Verified causes move to active fundraising.", + "step3Title": "Contribute & track", + "step3Body": "Donate to causes you believe in and track every transaction on the Stellar explorer.", + "step4Title": "On-chain outcomes", + "step4Body": "Funded causes are marked on-chain. Unfunded causes trigger automatic refunds to contributors.", + "whoCanTitle": "Who can participate?", + "whoCanBody": "Anyone with a Stellar wallet can vote on causes and contribute funds. Cause creators connect their wallet and fill out a short form to submit their proposal.", + "faqTitle": "Frequently asked questions", + "faq1Q": "Is ProofOfHeart open source?", + "faq1A": "Yes. The smart contracts and frontend are publicly available on GitHub.", + "faq2Q": "Which wallet do I need?", + "faq2A": "Any Stellar-compatible wallet works, including Freighter and LOBSTR.", + "faq3Q": "Are donations refundable?", + "faq3A": "If a cause does not reach its funding goal, the smart contract automatically returns contributions to donors.", + "faq4Q": "How are causes moderated?", + "faq4A": "Platform administrators review submissions before they go live. The community then votes to signal broader trust." + }, + "Causes": { + "pageTitle": "Community Causes", + "pageSubtitle": "Browse, search, and vote on causes that matter to you.", + "searchPlaceholder": "Search causes by title, description, or category…", + "labelCategory": "Category", + "labelStatus": "Status", + "labelSortBy": "Sort by", + "all": "All", + "allCategories": "All Categories", + "categoryChipAriaSelected": "{label}, {count} causes, selected", + "categoryChipAriaUnselected": "{label}, {count} causes", + "allStatuses": "All Statuses", + "clearFilters": "Clear filters", + "clearAllFilters": "Clear all filters", + "causesFound_one": "{count} cause found", + "causesFound_other": "{count} causes found", + "causesFoundFor": " for \"{query}\"", + "processingVote": "Processing vote…", + "noCausesYet": "No causes yet", + "noCausesFound": "No causes found", + "noSearchResults": "No search results", + "beFirstToSubmit": "Be the first to submit a cause for the community to vote on.", + "tryDifferentKeyword": "Try a different keyword or clear the filters.", + "tryDifferentSearch": "Try different search terms or clear the filters to see all causes.", + "tryAgain": "Try again", + "loadMore": "Load more causes", + "showingRange": "Showing {shown} of {total}", + "listView": "List", + "mapView": "Map" + }, + "Status": { + "active": "Active", + "cancelled": "Cancelled", + "funded": "Funded", + "failed": "Failed", + "verified": "Verified", + "verifiedCampaign": "Verified campaign" + }, + "CreateCampaign": { + "pageTitle": "Create a Campaign", + "pageSubtitle": "Fill in the details below to launch your campaign on-chain via Freighter.", + "walletGuard": "Connect your Freighter wallet to create a campaign.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting…", + "labelTitle": "Campaign Title", + "placeholderTitle": "A clear, compelling title for your campaign", + "labelDescription": "Description", + "placeholderDescription": "Describe your campaign, what it aims to achieve, and how funds will be used", + "labelCreatorEmailOptional": "Creator Email (Optional)", + "placeholderCreatorEmail": "name@example.com", + "creatorEmailNote": "Optional, off-chain only. Used for campaign milestone and deadline notifications.", + "labelFundingGoal": "Funding Goal (XLM)", + "labelDuration": "Duration (days)", + "placeholderDuration": "1–365", + "labelCategory": "Category", + "startupRevenueHint": "Revenue sharing is available for Educational Startup campaigns.", + "revenueSharingTitle": "Revenue Sharing", + "revenueSharingDesc": "Allow contributors to earn a share of future revenue.", + "revenueSharingAriaLabel": "Revenue Sharing", + "labelRevenueSharePct": "Revenue Share Percentage", + "cancel": "Cancel", + "launchCampaign": "Launch Campaign", + "reviewTitle": "Review Campaign Before Signing", + "reviewSubtitle": "Launching is irreversible on-chain. Confirm these details before signing in Freighter.", + "reviewFieldTitle": "Title", + "reviewFieldCreatorEmail": "Creator Email", + "reviewFieldFundingGoal": "Funding Goal", + "reviewFieldDuration": "Duration", + "reviewFieldDurationDays_one": "{count} day", + "reviewFieldDurationDays_other": "{count} days", + "reviewFieldCategory": "Category", + "reviewFieldRevenueShare": "Revenue Share", + "reviewRevenueShareNone": "0.00% (Not enabled)", + "reviewCreatorEmailNone": "No email provided", + "reviewFieldEndDate": "End Date (Your Timezone)", + "reviewFieldTimestamp": "Estimated Deadline Timestamp", + "editDetails": "Edit Details", + "confirmAndSign": "Confirm & Sign", + "submitting": "Submitting…", + "successMessage": "Campaign \"{title}\" created successfully!", + "emailWebhookFailed": "Campaign created, but we could not save your optional email subscription.", + "walletRequiredError": "Please connect your Freighter wallet before creating a campaign.", + "validationTitleRequired": "Title is required.", + "validationTitleTooLong": "Title must be 100 characters or fewer.", + "validationDescriptionRequired": "Description is required.", + "validationDescriptionTooLong": "Description must be 1,000 characters or fewer.", + "validationCreatorEmailInvalid": "Enter a valid email address or leave this field empty.", + "validationFundingGoalInvalid": "Funding goal must be greater than 0 XLM.", + "validationDurationInvalid": "Duration must be between 1 and 365 days.", + "validationRevenueShareInvalid": "Revenue share must be between 0.01% and 50%.", + "labelDescriptionLanguage": "Description Language", + "tabEnglish": "English", + "tabSpanish": "Spanish (optional)", + "placeholderDescriptionEs": "Describe tu campaña en español (opcional)", + "validationDescriptionEsTooLong": "Spanish description must be 1,000 characters or fewer." + }, + "Footer": { + "tagline": "A decentralized launchpad where the community validates causes and contributions are accounted for on-chain.", + "productHeading": "Product", + "linksHeading": "Links", + "home": "Home", + "exploreCauses": "Explore Causes", + "about": "About", + "github": "GitHub", + "stellar": "Stellar", + "rights": "© {year} ProofOfHeart. All rights reserved.", + "builtWith": "Built with Next.js + Stellar" + }, + "Explore": { + "title": "Explore", + "subtitle": "Browse causes, see community validation, and support what matters.", + "all": "All", + "noCausesYet": "No causes yet", + "noCausesInCategory": "No causes in this category", + "checkBackSoon": "Check back soon — the community is just getting started.", + "tryDifferentCategory": "Try selecting a different category above.", + "tryAgain": "Try again" + }, + "DonationModal": { + "titleConfirmed": "Donation Confirmed", + "titleFund": "Fund This Cause", + "closeAriaLabel": "Close", + "percentFunded": "{percent}% funded", + "amountLabel": "Amount (XLM)", + "amountPlaceholder": "e.g. 10", + "afterDonation": "After your donation: {percent}% funded", + "goalReached": "🎉 Goal reached!", + "platformFeeNote": "A platform fee of {feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.", + "donate": "Donate", + "donateWithAmount": "Donate {amount} XLM", + "waitingForSignature": "Waiting for Freighter signature…", + "waitingForConfirmation": "Waiting for ledger confirmation…", + "submittingTransaction": "Submitting transaction to the network…", + "donatedSuccess": "{amount} XLM donated successfully", + "thankYou": "Thank you for supporting this cause.", + "viewOnExplorer": "View on Stellar Explorer →", + "close": "Close", + "scientificNotation": "Scientific notation is not allowed.", + "invalidNumber": "Please enter a valid number.", + "invalidAmount": "Please enter a valid amount.", + "amountMustBePositive": "Amount must be greater than zero.", + "invalidNumberFormat": "Invalid number format.", + "maxDecimalPlaces": "Maximum 7 decimal places allowed.", + "amountExceedsRemainingGoal": "Amount exceeds the remaining funding goal.", + "campaignAlreadyFunded": "This cause is already fully funded." + }, + "WithdrawFunds": { + "withdrawalSuccessToast": "Withdrawal successful! You received {amount} XLM.", + "withdrawalSuccessful": "Withdrawal Successful", + "amountReceived": "Amount received:", + "platformFeeLabel": "Platform fee:", + "transaction": "Transaction:", + "viewOnExplorer": "View on Explorer", + "title": "Withdraw Funds", + "totalRaised": "Total raised", + "platformFeeWithPercent": "Platform fee ({percent}%)", + "youWillReceive": "You will receive", + "confirmMessage": "Are you sure you want to withdraw? This action cannot be undone. You will withdraw {total} XLM total, pay {fee} XLM in platform fees, and receive {net} XLM.", + "signing": "Signing…", + "confirming": "Confirming…", + "processing": "Processing…", + "confirmWithdrawal": "Confirm Withdrawal", + "cancel": "Cancel", + "withdrawFunds": "Withdraw Funds", + "disabledCancelled": "Campaign has been cancelled", + "disabledAlreadyWithdrawn": "Funds have already been withdrawn", + "disabledGoalNotReached": "Funding goal has not been reached", + "disabledStillActive": "Campaign is still active" + }, + "MultiSigPanel": { + "title": "Multi-Signature Withdrawal", + "description": "Require multiple organization members to approve before withdrawing funds. Set up co-signers and a signature threshold.", + "setupProposal": "Set Up Multi-Sig Withdrawal", + "signersLabel": "Co-signer Wallet Addresses", + "signerPlaceholder": "G… Stellar address", + "removeSigner": "Remove signer", + "addSigner": "Add another signer", + "thresholdLabel": "Required signatures", + "thresholdOf": "of {total} signers", + "createProposal": "Create Proposal", + "cancel": "Cancel", + "cancelProposal": "Cancel Proposal", + "proposalCreated": "Withdrawal proposal created. Share it with your co-signers.", + "signaturesProgress": "{signed} of {required} signatures collected", + "statusPending": "Pending", + "statusReady": "Ready to Execute", + "signProposal": "Sign Proposal", + "signedSuccess": "You signed the withdrawal proposal.", + "executeWithdrawal": "Execute Withdrawal ({amount} XLM)", + "executedTitle": "Multi-sig withdrawal executed successfully.", + "viewOnExplorer": "View on Explorer", + "signing": "Signing…", + "confirming": "Confirming…", + "processing": "Processing…", + "withdrawalSuccess": "Withdrawal successful! You received {amount} XLM.", + "errorNoSigners": "Please add at least one co-signer address.", + "errorInvalidThreshold": "Required signatures must be between 1 and the number of co-signers." + }, + "Voting": { + "title": "Vote on this cause", + "connectWalletToVote": "Please connect your wallet to vote.", + "mustHoldTokens": "You must hold governance tokens to vote.", + "alreadyVotedWarning": "You have already voted on this cause.", + "approveCampaignAria": "Approve campaign", + "rejectCampaignAria": "Reject campaign", + "connectWalletTitle": "Connect your wallet to vote", + "tokenHoldersOnly": "Token holders only", + "alreadyVotedTitle": "Already voted", + "approve": "Approve", + "reject": "Reject", + "processingVote": "Processing vote...", + "netVotes": "Net votes ({total} total)", + "approveRejectPercent": "{approve}% Approve / {reject}% Reject", + "approveCount": "{count} Approve", + "rejectCount": "{count} Reject", + "quorumProgress": "Quorum progress", + "votesOfQuorum": "{current} of {quorum} votes", + "approvalThreshold": "Approval threshold", + "approvalOfThreshold": "{current}% of {threshold}%", + "needMoreVotes": "Need {count} more votes to reach quorum.", + "needMoreVotes_one": "Need {count} more vote to reach quorum.", + "needMoreVotes_other": "Need {count} more votes to reach quorum.", + "needMoreApproval": "Need {count} more approval points to reach the approval threshold.", + "needMoreApproval_one": "Need {count} more approval point to reach the approval threshold.", + "needMoreApproval_other": "Need {count} more approval points to reach the approval threshold.", + "quorumReached": "Quorum and approval threshold reached. Anyone can finalize verification.", + "connectWalletPrompt": "Connect your wallet to vote on this cause", + "tokenHoldersOnlyPrompt": "Only governance token holders can vote on causes", + "votedUpvote": "You voted to approve this cause", + "votedDownvote": "You voted to reject this cause", + "verifyWithVotes": "✓ Verify Campaign with Votes", + "verifying": "Verifying..." + }, + "Observability": { + "loading": "Loading observability metrics…", + "metricsRequestFailed": "Metrics request failed ({status})", + "loadError": "Failed to load metrics", + "healthTitle": "RPC & transaction health", + "healthSubtitle": "Rolling window: {minutes} min · Updated {time}", + "refresh": "Refresh", + "simulationFailures": "Simulation failures", + "submissionFailures": "Submission failures", + "rpcTimeouts": "RPC timeouts", + "contractErrors": "Contract errors", + "failuresByKind": "Failures by kind", + "noEventsInWindow": "No events in the current window.", + "contractErrorCodes": "Contract error codes", + "noContractErrors": "No contract errors in the current window.", + "recentEvents": "Recent events", + "noRecentEvents": "No recent events recorded yet." + }, + "CauseJsonLd": { + "donateActionName": "Support {title}", + "fundingGoalLabel": "Funding Goal", + "amountRaisedLabel": "Amount Raised", + "deadlineLabel": "Funding Deadline" + }, + "Profile": { + "noWallet": "Connect your wallet to view your profile.", + "noWalletSub": "Your impact, contributions, and saved campaigns will appear here.", + "connectWallet": "Connect Wallet", + "connecting": "Connecting...", + "walletAddress": "Wallet address", + "totalContributed": "Total Contributed", + "campaignsBacked": "Campaigns Backed", + "campaignsCreated": "Campaigns Created", + "savedCampaigns": "Saved Campaigns", + "createdCampaigns": "Created Campaigns", + "noSavedCampaigns": "No saved campaigns yet.", + "browseCauses": "Browse causes" + }, + "Onboarding": { + "tourLabel": "Onboarding Tour", + "skip": "Skip", + "back": "Back", + "next": "Next", + "done": "Done", + "stepProgress": "Step {current} of {total}", + "step_connect_icon": "👛", + "step_connect_title": "Connect Wallet", + "step_connect_body": "Connect your Stellar wallet to get started.", + "step_chooseCause_icon": "❤️", + "step_chooseCause_title": "Choose a Cause", + "step_chooseCause_body": "Browse and select a cause you want to support.", + "step_contribute_icon": "💸", + "step_contribute_title": "Contribute", + "step_contribute_body": "Make a donation to help the cause reach its funding goal.", + "step_confirm_icon": "🔗", + "step_confirm_title": "Track Impact", + "step_confirm_body": "Your contribution is recorded on the Stellar blockchain." + }, + "CampaignMap": { + "emptyTitle": "No campaigns with location data", + "emptyBody": "Campaigns with coordinates will appear on this map. Switch to list view to browse all campaigns." + }, + "Gamification": { + "level_Bronze": "Bronze", + "level_Silver": "Silver", + "level_Gold": "Gold", + "level_Platinum": "Platinum", + "level_Diamond": "Diamond", + "badge_early_backer_name": "Early Backer", + "badge_early_backer_desc": "Supported ProofOfHeart in its early days", + "badge_streak_master_name": "Streak Master", + "badge_streak_master_desc": "Made 3 or more donations", + "badge_whale_name": "Whale", + "badge_whale_desc": "Donated 1,000 XLM or more in total", + "badge_heart_champion_name": "Heart Champion", + "badge_heart_champion_desc": "Reached Diamond level (5,000+ XLM donated)", + "locked": "Locked" + }, + "ContributorLeaderboard": { + "title": "Top Supporters", + "subtitle": "Leaderboard", + "emptyMessage": "Be the first supporter for this cause! 💜", + "youBadge": "You", + "hideWallet": "Hide my wallet address", + "anonymousState": "✓ Anonymous", + "optOutState": "Opt out", + "optOutTooltip": "Contributors can opt out at any time to remain anonymous on public leaderboards.", + "disableAnon": "Disable anonymous mode", + "enableAnon": "Enable anonymous mode" + }, + "DonatorBadges": { + "level": "Level {levelNumber}: {levelName}", + "totalXlm": "{amount} XLM Total", + "nextXlm": "Next: {amount} XLM", + "earnedBadges": "Earned Badges" + }, + "CauseDetail": { + "noDescription": "No description provided by the creator." + }, + "FiatOnramp": { + "buyWithCard": "Buy XLM with card", + "buyWithCardDescription": "New to crypto? Purchase XLM with a card or bank transfer, then donate.", + "opening": "Opening secure checkout…", + "error": "Could not open the payment provider. Please try again.", + "poweredBy": "Powered by {provider}" + } +} diff --git a/src/__tests__/app/api/admin-audit-log/route.test.ts b/src/__tests__/app/api/admin-audit-log/route.test.ts new file mode 100644 index 00000000..9c154c19 --- /dev/null +++ b/src/__tests__/app/api/admin-audit-log/route.test.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; + +jest.mock("next/server", () => ({ + NextResponse: { + json: (data: unknown) => ({ status: 200, json: async () => data }), + }, +})); + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +const parsePage = (raw: string | null): number => { + const parsed = parseInt(raw ?? "1", 10); + return Math.max(1, Number.isFinite(parsed) ? parsed : 1); +}; + +const parsePageSize = (raw: string | null): number => { + const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10); + return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE)); +}; + +const pickFields = (item: T, fields: string[]): Partial => { + const picked: Record = {}; + for (const field of fields) { + if (field in item) { + picked[field] = (item as Record)[field]; + } + } + return picked as Partial; +}; + +describe("admin-audit-log route pagination helpers", () => { + it("parses page correctly", () => { + expect(parsePage(null)).toBe(1); + expect(parsePage("1")).toBe(1); + expect(parsePage("5")).toBe(5); + expect(parsePage("0")).toBe(1); + expect(parsePage("-1")).toBe(1); + expect(parsePage("abc")).toBe(1); + }); + + it("parses pageSize correctly", () => { + expect(parsePageSize(null)).toBe(20); + expect(parsePageSize("10")).toBe(10); + expect(parsePageSize("100")).toBe(100); + expect(parsePageSize("0")).toBe(1); + expect(parsePageSize("999")).toBe(100); + expect(parsePageSize("abc")).toBe(20); + }); + + it("picks only requested fields", () => { + const item = { adminAddress: "GAAAA", action: "verify", txHash: "0x123", timestamp: 1000 }; + expect(pickFields(item, ["adminAddress", "action"])).toEqual({ + adminAddress: "GAAAA", + action: "verify", + }); + expect(pickFields(item, ["txHash"])).toEqual({ txHash: "0x123" }); + expect(pickFields(item, ["missing"])).toEqual({}); + expect(pickFields(item, [])).toEqual({}); + }); +}); + +describe("admin-audit-log route pagination logic", () => { + const buildEntries = (count: number) => + Array.from({ length: count }, (_, i) => ({ + adminAddress: "GAAAAAAA", + action: "verify_campaign" as const, + txHash: `tx-${i}`, + timestamp: 1000 + i, + campaignId: i, + details: `details-${i}`, + })); + + it("paginates a list of entries", () => { + const entries = buildEntries(45); + const page = 1; + const pageSize = 20; + const start = (page - 1) * pageSize; + const pageEntries = entries.slice(start, start + pageSize); + + expect(pageEntries).toHaveLength(20); + expect(pageEntries[0]).toEqual(entries[0]); + expect(pageEntries[19]).toEqual(entries[19]); + }); + + it("returns the last page with fewer items", () => { + const entries = buildEntries(45); + const page = 3; + const pageSize = 20; + const start = (page - 1) * pageSize; + const pageEntries = entries.slice(start, start + pageSize); + + expect(pageEntries).toHaveLength(5); + expect(pageEntries[0]).toEqual(entries[40]); + }); + + it("filters entries by adminAddress", () => { + const entries = [ + { adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-1", timestamp: 1000 }, + { adminAddress: "GBBBBBBB", action: "reject_campaign" as const, txHash: "tx-2", timestamp: 1001 }, + { adminAddress: "GAAAAAAA", action: "update_platform_fee" as const, txHash: "tx-3", timestamp: 1002 }, + ]; + + const normalized = "GAAAAAAA"; + const filtered = entries.filter((e) => e.adminAddress.toUpperCase() === normalized); + expect(filtered).toHaveLength(2); + expect(filtered.every((e) => e.adminAddress === "GAAAAAAA")).toBe(true); + }); + + it("filters entries by action", () => { + const entries = [ + { adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-1", timestamp: 1000 }, + { adminAddress: "GAAAAAAA", action: "reject_campaign" as const, txHash: "tx-2", timestamp: 1001 }, + { adminAddress: "GAAAAAAA", action: "verify_campaign" as const, txHash: "tx-3", timestamp: 1002 }, + ]; + + const action = "verify_campaign"; + const filtered = entries.filter((e) => e.action === action); + expect(filtered).toHaveLength(2); + expect(filtered.every((e) => e.action === "verify_campaign")).toBe(true); + }); + + it("applies sparse fieldsets correctly", () => { + const entries = buildEntries(3); + const fields = ["adminAddress", "action"]; + const picked = entries.map((entry) => pickFields(entry, fields)); + + expect(picked[0]).toEqual({ adminAddress: "GAAAAAAA", action: "verify_campaign" }); + expect(picked[0]).not.toHaveProperty("txHash"); + expect(picked[0]).not.toHaveProperty("timestamp"); + }); +}); diff --git a/src/__tests__/app/api/reports/route.test.ts b/src/__tests__/app/api/reports/route.test.ts new file mode 100644 index 00000000..5f25854e --- /dev/null +++ b/src/__tests__/app/api/reports/route.test.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; + +jest.mock("next/server", () => ({ + NextResponse: { + json: (data: unknown) => ({ status: 200, json: async () => data }), + }, +})); + +const mockRequest = (url: string, searchParams: Record = {}) => ({ + url: searchParams.size + ? `${url}?${new URLSearchParams(searchParams).toString()}` + : url, +}); + +describe("reports route pagination helpers", () => { + const parsePage = (raw: string | null): number => { + const parsed = parseInt(raw ?? "1", 10); + return Math.max(1, Number.isFinite(parsed) ? parsed : 1); + }; + + const parsePageSize = (raw: string | null): number => { + const parsed = parseInt(raw ?? "20", 10); + return Math.min(100, Math.max(1, Number.isFinite(parsed) ? parsed : 20)); + }; + + const pickFields = (item: T, fields: string[]): Partial => { + const picked: Record = {}; + for (const field of fields) { + if (field in item) { + picked[field] = (item as Record)[field]; + } + } + return picked as Partial; + }; + + it("parses page correctly", () => { + expect(parsePage(null)).toBe(1); + expect(parsePage("1")).toBe(1); + expect(parsePage("5")).toBe(5); + expect(parsePage("0")).toBe(1); + expect(parsePage("-1")).toBe(1); + expect(parsePage("abc")).toBe(1); + }); + + it("parses pageSize correctly", () => { + expect(parsePageSize(null)).toBe(20); + expect(parsePageSize("10")).toBe(10); + expect(parsePageSize("100")).toBe(100); + expect(parsePageSize("0")).toBe(1); + expect(parsePageSize("999")).toBe(100); + expect(parsePageSize("abc")).toBe(20); + }); + + it("picks only requested fields", () => { + const item = { id: "1", name: "test", extra: "data" }; + expect(pickFields(item, ["id", "name"])).toEqual({ id: "1", name: "test" }); + expect(pickFields(item, ["id"])).toEqual({ id: "1" }); + expect(pickFields(item, ["missing"])).toEqual({}); + expect(pickFields(item, [])).toEqual({}); + }); +}); + +describe("reports route integration", () => { + let reportStore: Array<{ + id: string; + campaignId: number; + campaignTitle: string; + reason: string; + notes: string; + reporterAddress: string | null; + timestamp: number; + status: "pending" | "reviewed"; + }>; + + beforeEach(() => { + jest.resetModules(); + reportStore = []; + jest.doMock("@/lib/reportStore", () => ({ + reportStore, + })); + }); + + it("paginates and filters reports via mocked route", async () => { + for (let i = 0; i < 45; i++) { + reportStore.push({ + id: `report-${i}`, + campaignId: i, + campaignTitle: `Campaign ${i}`, + reason: "scam", + notes: "notes", + reporterAddress: "GABC", + timestamp: 1000 + i, + status: i % 2 === 0 ? "pending" : "reviewed", + }); + } + + const { GET } = await import("@/app/api/reports/route"); + + const req1 = mockRequest("http://localhost/api/reports", { + page: "1", + pageSize: "20", + }) as any; + const res1 = await GET(req1); + const data1 = await (res1 as any).json(); + + expect(data1.items).toHaveLength(20); + expect(data1.total).toBe(45); + expect(data1.page).toBe(1); + expect(data1.pageSize).toBe(20); + expect(data1.hasMore).toBe(true); + }); +}); diff --git a/src/app/api/admin-audit-log/route.ts b/src/app/api/admin-audit-log/route.ts index 92f581f2..1dbafe97 100644 --- a/src/app/api/admin-audit-log/route.ts +++ b/src/app/api/admin-audit-log/route.ts @@ -51,21 +51,58 @@ function normalizeAddress(address: string): string { return address.trim().toUpperCase(); } +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +function parsePageSize(raw: string | null): number { + const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10); + return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE)); +} + +function parsePage(raw: string | null): number { + const parsed = parseInt(raw ?? "1", 10); + return Math.max(1, Number.isFinite(parsed) ? parsed : 1); +} + +function pickFields(item: T, fields: string[]): Partial { + const picked: Record = {}; + for (const field of fields) { + if (field in item) { + picked[field] = (item as Record)[field]; + } + } + return picked as Partial; +} + +// GET /api/admin-audit-log?page=1&pageSize=20&adminAddress=...&action=...&fields=... export async function GET(request: Request) { const url = new URL(request.url); const adminAddress = url.searchParams.get("adminAddress"); - const entries = await readEntries(); + const action = url.searchParams.get("action"); + const page = parsePage(url.searchParams.get("page")); + const pageSize = parsePageSize(url.searchParams.get("pageSize")); + const fields = url.searchParams.get("fields"); + const requestedFields = fields ? fields.split(",").map((f) => f.trim()).filter(Boolean) : []; + + let entries = await readEntries(); - if (!adminAddress) { - return NextResponse.json({ entries }); + if (adminAddress) { + const normalized = normalizeAddress(adminAddress); + entries = entries.filter((entry) => normalizeAddress(entry.adminAddress) === normalized); } - const normalized = normalizeAddress(adminAddress); - return NextResponse.json({ - entries: entries - .filter((entry) => normalizeAddress(entry.adminAddress) === normalized) - .sort((a, b) => b.timestamp - a.timestamp), - }); + if (action) { + entries = entries.filter((entry) => entry.action === action); + } + + entries.sort((a, b) => b.timestamp - a.timestamp); + const total = entries.length; + const start = (page - 1) * pageSize; + const pageEntries = entries.slice(start, start + pageSize).map((entry) => + requestedFields.length > 0 ? pickFields(entry, requestedFields) : entry, + ); + + return NextResponse.json({ entries: pageEntries, total, page, pageSize, hasMore: page * pageSize < total }); } export async function POST(request: Request) { diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index fd0d7a96..46ead5d1 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -19,20 +19,53 @@ function checkRateLimit(key: string): boolean { return true; } -// GET /api/reports — admin moderation queue +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +function parsePageSize(raw: string | null): number { + const parsed = parseInt(raw ?? String(DEFAULT_PAGE_SIZE), 10); + return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_PAGE_SIZE)); +} + +function parsePage(raw: string | null): number { + const parsed = parseInt(raw ?? "1", 10); + return Math.max(1, Number.isFinite(parsed) ? parsed : 1); +} + +function pickFields(item: T, fields: string[]): Partial { + const picked: Record = {}; + for (const field of fields) { + if (field in item) { + picked[field] = (item as Record)[field]; + } + } + return picked as Partial; +} + +// GET /api/reports?page=1&pageSize=20&status=all&fields=id,campaignId,status export async function GET(req: NextRequest) { const url = new URL(req.url); const status = url.searchParams.get("status") ?? "all"; + const page = parsePage(url.searchParams.get("page")); + const pageSize = parsePageSize(url.searchParams.get("pageSize")); + const fields = url.searchParams.get("fields"); + const requestedFields = fields ? fields.split(",").map((f) => f.trim()).filter(Boolean) : []; - const results = + const filtered = status === "pending" ? reportStore.filter((r) => r.status === "pending") : status === "reviewed" ? reportStore.filter((r) => r.status === "reviewed") : [...reportStore]; - results.sort((a, b) => b.timestamp - a.timestamp); - return NextResponse.json(results); + filtered.sort((a, b) => b.timestamp - a.timestamp); + const total = filtered.length; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize).map((item) => + requestedFields.length > 0 ? pickFields(item, requestedFields) : item, + ); + + return NextResponse.json({ items, total, page, pageSize, hasMore: page * pageSize < total }); } // POST /api/reports — submit a new abuse report diff --git a/src/components/CommentItem.tsx b/src/components/CommentItem.tsx index ef0e610f..25893dce 100644 --- a/src/components/CommentItem.tsx +++ b/src/components/CommentItem.tsx @@ -10,6 +10,7 @@ interface CommentItemProps { comment: Comment; replies?: Comment[]; isCreator: boolean; + creatorAddress?: string; onReply: (content: string, parentId: string) => Promise; onPin?: (commentId: string, isPinned: boolean) => Promise; onReport: (commentId: string) => Promise; @@ -45,6 +46,7 @@ export default function CommentItem({ comment, replies = [], isCreator, + creatorAddress, onReply, onPin, onReport, @@ -118,6 +120,16 @@ export default function CommentItem({ Verified )} + {creatorAddress && + comment.authorAddress.toLowerCase() === + creatorAddress.toLowerCase() && ( + + Creator + + )} {comment.isReported && ( Reported diff --git a/src/components/CommentsList.tsx b/src/components/CommentsList.tsx index 23571832..e432bdfc 100644 --- a/src/components/CommentsList.tsx +++ b/src/components/CommentsList.tsx @@ -9,6 +9,7 @@ interface CommentsListProps { isLoading: boolean; error: string | null; isCreator: boolean; + creatorAddress?: string; onReply: (content: string, parentId: string) => Promise; onPin: (commentId: string, isPinned: boolean) => Promise; onReport: (commentId: string) => Promise; @@ -19,6 +20,7 @@ export default function CommentsList({ isLoading, error, isCreator, + creatorAddress, onReply, onPin, onReport, @@ -108,6 +110,7 @@ export default function CommentsList({ comment={comment} replies={repliesMap.get(comment.id) || []} isCreator={isCreator} + creatorAddress={creatorAddress} onReply={onReply} onPin={onPin} onReport={onReport} diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx index 03973599..e8eb1d13 100644 --- a/src/components/CommentsSection.tsx +++ b/src/components/CommentsSection.tsx @@ -58,6 +58,7 @@ export default function CommentsSection({ campaign }: CommentsSectionProps) { isLoading={isLoading} error={error} isCreator={isCreator} + creatorAddress={campaign.creator} onReply={createComment} onPin={pinCommentMutation} onReport={reportCommentMutation} diff --git a/src/hooks/useCampaignComments.ts b/src/hooks/useCampaignComments.ts index 79c1796f..3158d9ae 100644 --- a/src/hooks/useCampaignComments.ts +++ b/src/hooks/useCampaignComments.ts @@ -47,12 +47,17 @@ export function useCampaignComments( const queryKey = ["campaignComments", campaignId, page]; const allQueryKey = ["campaignComments", campaignId]; - const { data, isLoading, error } = useQuery({ - queryKey, - queryFn: () => getCampaignComments(campaignId, page, PAGE_SIZE), - enabled: !!campaignId, - placeholderData: (prev) => prev, - }); +const POLL_INTERVAL_MS = 15_000; + +const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: () => getCampaignComments(campaignId, page, PAGE_SIZE), + enabled: !!campaignId, + placeholderData: (prev) => prev, + refetchInterval: POLL_INTERVAL_MS, + refetchIntervalInBackground: false, + staleTime: 0, +}); const { mutateAsync: createMutation, isPending: isCreating } = useMutation({ mutationFn: async ({ content, parentId }: { content: string; parentId: string | null }) => { diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts index b414af0c..1e1886bf 100644 --- a/src/i18n/routing.ts +++ b/src/i18n/routing.ts @@ -4,7 +4,7 @@ import { LOCALE_COOKIE_MAX_AGE } from "@/lib/preferences"; export const routing = defineRouting({ // A list of all locales that are supported - locales: ["en", "es"], + locales: ["en", "es", "fr", "pt"], // Used when no locale matches defaultLocale: "en", diff --git a/src/lib/adminLog.ts b/src/lib/adminLog.ts index 273f8384..1f705fa7 100644 --- a/src/lib/adminLog.ts +++ b/src/lib/adminLog.ts @@ -46,11 +46,17 @@ function writeAllEntries(entries: AdminAuditLogEntry[]): void { } } -async function readApiEntries(adminAddress?: string): Promise { +async function readApiEntries( + adminAddress?: string, + page = 1, + pageSize = 20, +): Promise<{ entries: AdminAuditLogEntry[]; total: number; hasMore: boolean }> { const url = new URL(API_ENDPOINT, window.location.origin); if (adminAddress) { url.searchParams.set("adminAddress", adminAddress); } + url.searchParams.set("page", String(page)); + url.searchParams.set("pageSize", String(pageSize)); const response = await fetch(url.toString(), { method: "GET", @@ -61,8 +67,16 @@ async function readApiEntries(adminAddress?: string): Promise { @@ -104,12 +118,14 @@ export async function getAdminAuditLog( limit = 50, ): Promise { const normalizedAddress = normalizeAddress(adminAddress); + const pageSize = Math.min(100, Math.max(1, limit)); + const page = Math.max(1, Math.ceil(limit / pageSize)); try { - const apiEntries = await readApiEntries(normalizedAddress); - if (apiEntries.length > 0) { - writeAllEntries(apiEntries); - return apiEntries.sort((a, b) => b.timestamp - a.timestamp).slice(0, Math.max(0, limit)); + const { entries, total, hasMore } = await readApiEntries(normalizedAddress, page, pageSize); + if (entries.length > 0 || total > 0) { + writeAllEntries(entries); + return entries.sort((a, b) => b.timestamp - a.timestamp).slice(0, Math.max(0, limit)); } } catch { // Fall back to local cache below.