diff --git a/src/__tests__/components/CauseCard.test.tsx b/src/__tests__/components/CauseCard.test.tsx index 8db62928..8b2ae45a 100644 --- a/src/__tests__/components/CauseCard.test.tsx +++ b/src/__tests__/components/CauseCard.test.tsx @@ -409,6 +409,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 @@ -419,6 +436,66 @@ 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()); + }); }); // ── Save (bookmark) ────────────────────────────────────────────────────────── diff --git a/src/app/[locale]/causes/[id]/CauseDetailClient.tsx b/src/app/[locale]/causes/[id]/CauseDetailClient.tsx index 9b27cecd..f2e057cd 100644 --- a/src/app/[locale]/causes/[id]/CauseDetailClient.tsx +++ b/src/app/[locale]/causes/[id]/CauseDetailClient.tsx @@ -57,6 +57,9 @@ import { isSameAddress } from "@/lib/stellar"; const EditCampaignMetadata = dynamic(() => import("@/components/EditCampaignMetadata"), { ssr: false, }); +const CampaignTransferPanel = dynamic(() => import("@/components/CampaignTransferPanel"), { + ssr: false, +}); export default function CauseDetailClient({ id }: { id: string }) { const { publicKey: userWalletAddress } = useWallet(); diff --git a/src/components/CampaignTransferPanel.tsx b/src/components/CampaignTransferPanel.tsx new file mode 100644 index 00000000..0ea2a677 --- /dev/null +++ b/src/components/CampaignTransferPanel.tsx @@ -0,0 +1,285 @@ +"use client"; + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { useEffect, useState } from "react"; +import { Loader2, ArrowRightLeft, XCircle, CheckCircle, UserPlus } from "lucide-react"; +import { useToast } from "@/components/ToastProvider"; +import { useWallet } from "@/components/WalletContext"; +import { + getCampaignTransfer, + initiateCampaignTransfer, + acceptCampaignTransfer, + cancelCampaignTransfer, +} from "@/lib/contractClient"; +import type { TransactionLifecyclePhase } from "@/lib/contractClient"; +import { isSameAddress } from "@/lib/stellar"; +import { parseContractError } from "@/utils/contractErrors"; + +interface CampaignTransferPanelProps { + campaignId: number; + creator: string; + onTransferComplete: () => void; +} + +export default function CampaignTransferPanel({ + campaignId, + creator, + onTransferComplete, +}: CampaignTransferPanelProps) { + const { publicKey } = useWallet(); + const { showSuccess, showError, showWarning } = useToast(); + + const [pendingRecipient, setPendingRecipient] = useState(null); + const [isLoadingTransfer, setIsLoadingTransfer] = useState(true); + const [transferInput, setTransferInput] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + const [txPhase, setTxPhase] = useState(null); + + const isCreator = publicKey ? isSameAddress(creator, publicKey) : false; + const isRecipient = + publicKey && pendingRecipient ? isSameAddress(pendingRecipient, publicKey) : false; + + const txPhaseLabel = + txPhase === "building" + ? "Preparing…" + : txPhase === "signing" + ? "Sign in wallet…" + : txPhase === "submitting" + ? "Submitting…" + : txPhase === "confirming" + ? "Confirming…" + : null; + + // Load pending transfer status + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const transfer = await getCampaignTransfer(campaignId); + if (!cancelled) setPendingRecipient(transfer); + } catch { + // No pending transfer or error — treat as none + if (!cancelled) setPendingRecipient(null); + } finally { + if (!cancelled) setIsLoadingTransfer(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, [campaignId]); + + // Refresh pending transfer after actions + const refreshTransfer = async () => { + try { + const transfer = await getCampaignTransfer(campaignId); + setPendingRecipient(transfer); + } catch { + setPendingRecipient(null); + } + }; + + const handleInitiateTransfer = async () => { + if (!publicKey) { + showWarning("Please connect your wallet first."); + return; + } + const address = transferInput.trim(); + if (!StellarSdk.StrKey.isValidEd25519PublicKey(address)) { + showError("Please enter a valid Stellar public key (G…)."); + return; + } + setIsProcessing(true); + setTxPhase(null); + try { + await initiateCampaignTransfer(campaignId, address, { + onStatus: ({ phase }) => setTxPhase(phase), + }); + showSuccess("Transfer initiated. The recipient must accept it."); + setTransferInput(""); + await refreshTransfer(); + onTransferComplete(); + } catch (err) { + showError(parseContractError(err)); + } finally { + setIsProcessing(false); + setTxPhase(null); + } + }; + + const handleAcceptTransfer = async () => { + if (!publicKey) { + showWarning("Please connect your wallet first."); + return; + } + setIsProcessing(true); + setTxPhase(null); + try { + await acceptCampaignTransfer(campaignId, { + onStatus: ({ phase }) => setTxPhase(phase), + }); + showSuccess("Campaign ownership transferred successfully!"); + setPendingRecipient(null); + onTransferComplete(); + } catch (err) { + showError(parseContractError(err)); + } finally { + setIsProcessing(false); + setTxPhase(null); + } + }; + + const handleCancelTransfer = async () => { + if (!publicKey) return; + setIsProcessing(true); + setTxPhase(null); + try { + await cancelCampaignTransfer(campaignId, { + onStatus: ({ phase }) => setTxPhase(phase), + }); + showSuccess("Transfer cancelled."); + setPendingRecipient(null); + onTransferComplete(); + } catch (err) { + showError(parseContractError(err)); + } finally { + setIsProcessing(false); + setTxPhase(null); + } + }; + + if (isLoadingTransfer) { + return ( +
+
+ + Loading transfer status… +
+
+ ); + } + + // ── Recipient view: banner to accept pending transfer ── + if (isRecipient && pendingRecipient) { + return ( +
+
+
+ +
+
+

+ Campaign Ownership Transfer +

+

+ The creator has started a transfer of this campaign to you. Accept to become the new + owner. +

+
+ +
+
+
+
+ ); + } + + // ── Creator view ── + if (isCreator) { + return ( +
+
+ +

+ Transfer Ownership +

+
+ + {pendingRecipient ? ( + // Pending transfer — show recipient + cancel +
+
+

+ Pending Transfer +

+

+ {pendingRecipient.slice(0, 10)}...{pendingRecipient.slice(-6)} +

+
+ +
+ ) : ( + // No pending transfer — show initiate form +
+

+ Transfer this campaign to another Stellar address. The recipient must accept the + transfer to complete it. +

+
+ setTransferInput(e.target.value)} + placeholder="G…" + className="flex-1 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg px-3 py-2 font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100 focus:border-purple-500 focus:outline-none transition" + /> + +
+
+ )} +
+ ); + } + + // Not creator, not recipient — show nothing + return null; +} diff --git a/src/components/CauseCard.tsx b/src/components/CauseCard.tsx index 282ab090..efd17c36 100644 --- a/src/components/CauseCard.tsx +++ b/src/components/CauseCard.tsx @@ -171,7 +171,10 @@ function CauseCard({ {/* Description */} - + {/* Funding progress */}
diff --git a/src/components/ThirdPartyScripts.tsx b/src/components/ThirdPartyScripts.tsx index 4ff41c43..0820962a 100644 --- a/src/components/ThirdPartyScripts.tsx +++ b/src/components/ThirdPartyScripts.tsx @@ -44,6 +44,8 @@ export default function ThirdPartyScripts() { // 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. + // `ScriptStrategy` already excludes it, so the compare is widened to + // `string` to keep the runtime check for config that bypasses the type. const safeStrategy = (strategy as string) === "beforeInteractive" ? (process.env.NODE_ENV !== "production" && diff --git a/src/components/WalletContext.tsx b/src/components/WalletContext.tsx index d32e09c8..e28777b0 100644 --- a/src/components/WalletContext.tsx +++ b/src/components/WalletContext.tsx @@ -27,12 +27,24 @@ import { isFreighterLockedError } from "@/utils/freighterErrors"; import InstallFreighterModal from "./InstallFreighterModal"; import SocialLoginButtons from "./SocialLoginButtons"; -interface WalletContextType { +/** + * Wallet state is split from wallet actions (#648). + * + * Previously one context held both, so every `isLoading` flip re-rendered each + * consumer — including components that only ever call `connectWallet` and never + * read state. Splitting means: + * + * - `useWalletState()` re-renders when connection state changes + * - `useWalletActions()` re-renders only when an action identity changes + * + * Both values are memoized, so a re-render of `WalletProvider` itself does not + * cascade into consumers unless the data they read actually changed. + */ + +interface WalletState { publicKey: string | null; isWalletConnected: boolean; walletNetworkWarning: string | null; - connectWallet: () => Promise; - disconnectWallet: () => void; isLoading: boolean; /** Which wallet backs the current session — null when disconnected. */ walletKind: WalletKind | null; @@ -40,12 +52,18 @@ interface WalletContextType { socialProfile: SocialWalletSession | null; /** Whether social login is available (a Web3Auth client id is configured). */ isSocialLoginAvailable: boolean; +} + +interface WalletActions { + connectWallet: () => Promise; + disconnectWallet: () => void; connectWithSocial: (provider: SocialLoginProvider) => Promise; } const MOCK_PUBLIC_KEY = IS_MOCK_MODE ? StellarSdk.Keypair.random().publicKey() : null; -const WalletContext = createContext(undefined); +const WalletStateContext = createContext(undefined); +const WalletActionsContext = createContext(undefined); export const WalletProvider = ({ children }: { children: ReactNode }) => { const [publicKey, setPublicKey] = useState(null); @@ -394,58 +412,88 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { ); }; - const contextValue = useMemo( + const state = useMemo( () => ({ publicKey, isWalletConnected, walletNetworkWarning, - connectWallet, - disconnectWallet, isLoading, walletKind, socialProfile, isSocialLoginAvailable: isSocialLoginConfigured(), - connectWithSocial, }), - [ - publicKey, - isWalletConnected, - walletNetworkWarning, - isLoading, - walletKind, - socialProfile, + [publicKey, isWalletConnected, walletNetworkWarning, isLoading, walletKind, socialProfile], + ); + + const actions = useMemo( + () => ({ + connectWallet, + disconnectWallet, connectWithSocial, - ], + }), + [connectWithSocial], ); return ( - - {children} - { - setShowInstallPrompt(false); - setIsFreighterLocked(false); - }} - onRetry={handleRetryInstall} - isLocked={isFreighterLocked} - socialLogin={ - isSocialLoginConfigured() ? ( - setShowInstallPrompt(false)} - /> - ) : undefined - } - /> - + + + {children} + { + setShowInstallPrompt(false); + setIsFreighterLocked(false); + }} + onRetry={handleRetryInstall} + isLocked={isFreighterLocked} + socialLogin={ + isSocialLoginConfigured() ? ( + setShowInstallPrompt(false)} + /> + ) : undefined + } + /> + + ); }; -export const useWallet = () => { - const ctx = useContext(WalletContext); - if (!ctx) throw new Error("useWallet must be used within a WalletProvider"); +/** Subscribe to wallet state only. Re-renders when connection state changes. */ +export const useWalletState = (): WalletState => { + const ctx = useContext(WalletStateContext); + if (!ctx) throw new Error("useWalletState must be used within a WalletProvider"); return ctx; }; + +/** + * Subscribe to wallet actions only. Prefer this in components that only trigger + * connect/disconnect (buttons, menu items) and never read connection state. + */ +export const useWalletActions = (): WalletActions => { + const ctx = useContext(WalletActionsContext); + if (!ctx) throw new Error("useWalletActions must be used within a WalletProvider"); + return ctx; +}; + +/** + * Combined accessor, kept so existing call sites keep working. Subscribes to + * both contexts — reach for `useWalletState` or `useWalletActions` instead when + * a component only needs one half. + */ +export const useWallet = () => { + // Reads the contexts directly rather than composing the two hooks above, so a + // call outside the provider still reports `useWallet` — the name the caller + // actually used — instead of leaking the split into the error message. + const state = useContext(WalletStateContext); + const actions = useContext(WalletActionsContext); + const value = useMemo( + () => (state && actions ? { ...state, ...actions } : null), + [state, actions], + ); + if (!value) throw new Error("useWallet must be used within a WalletProvider"); + return value; +}; diff --git a/src/lib/contractClient.ts b/src/lib/contractClient.ts index c87e2fc4..cbfe5c6e 100644 --- a/src/lib/contractClient.ts +++ b/src/lib/contractClient.ts @@ -1094,6 +1094,144 @@ export async function claimRefund( } } +// --------------------------------------------------------------------------- +// Campaign ownership transfer — 2-step process (initiate → accept) +// --------------------------------------------------------------------------- + +/** + * View function: get the pending transfer recipient for a campaign (if any). + * Returns the recipient address string, or null if no transfer is pending. + */ +export async function getCampaignTransfer(campaignId: number): Promise { + if (USE_MOCKS) return null; + try { + const result = await invokeViewMethod("get_campaign_transfer", [ + StellarSdk.nativeToScVal(campaignId, { type: "u32" }), + ]); + if (!result) return null; + return StellarSdk.Address.fromScVal(result).toString(); + } catch (err) { + const code = getContractErrorCode(err); + // No pending transfer is treated as null, not an error + if (code !== null) return null; + throw new Error(parseContractError(err)); + } +} + +/** + * Initiate a two-step campaign ownership transfer (creator only). + * Starts a transfer to `newOwner`; the recipient must call + * `acceptCampaignTransfer` to finalise. + */ +export async function initiateCampaignTransfer( + campaignId: number, + newOwner: string, + options?: TransactionLifecycleOptions, +): Promise { + validateStellarAddress(newOwner); + if (USE_MOCKS) return emitMockLifecycle("mock_tx_initiate_campaign_transfer", options); + + const callerAddress = await getSignerAddress(); + const contract = new StellarSdk.Contract(CONTRACT_ADDRESS); + const op = contract.call( + "initiate_campaign_transfer", + StellarSdk.nativeToScVal(campaignId, { type: "u32" }), + new StellarSdk.Address(newOwner).toScVal(), + ); + + try { + const txResult = await buildAndSubmitTransaction(callerAddress, op, { + ...options, + operation: "initiate_campaign_transfer", + }); + return txResult.txHash; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const errorCode = getContractErrorCode(err); + captureTransactionError( + "initiate_campaign_transfer", + campaignId, + error, + errorCode ? String(errorCode) : undefined, + ); + throw new Error(parseContractError(err)); + } +} + +/** + * Accept a pending campaign ownership transfer (recipient only). + * Must be called by the address that was set as the new owner during + * `initiateCampaignTransfer`. + */ +export async function acceptCampaignTransfer( + campaignId: number, + options?: TransactionLifecycleOptions, +): Promise { + if (USE_MOCKS) return emitMockLifecycle("mock_tx_accept_campaign_transfer", options); + + const callerAddress = await getSignerAddress(); + const contract = new StellarSdk.Contract(CONTRACT_ADDRESS); + const op = contract.call( + "accept_campaign_transfer", + StellarSdk.nativeToScVal(campaignId, { type: "u32" }), + ); + + try { + const txResult = await buildAndSubmitTransaction(callerAddress, op, { + ...options, + operation: "accept_campaign_transfer", + }); + return txResult.txHash; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const errorCode = getContractErrorCode(err); + captureTransactionError( + "accept_campaign_transfer", + campaignId, + error, + errorCode ? String(errorCode) : undefined, + ); + throw new Error(parseContractError(err)); + } +} + +/** + * Cancel a pending campaign ownership transfer (creator only). + * The current creator can cancel an outstanding transfer before the + * recipient accepts it. + */ +export async function cancelCampaignTransfer( + campaignId: number, + options?: TransactionLifecycleOptions, +): Promise { + if (USE_MOCKS) return emitMockLifecycle("mock_tx_cancel_campaign_transfer", options); + + const callerAddress = await getSignerAddress(); + const contract = new StellarSdk.Contract(CONTRACT_ADDRESS); + const op = contract.call( + "cancel_campaign_transfer", + StellarSdk.nativeToScVal(campaignId, { type: "u32" }), + ); + + try { + const txResult = await buildAndSubmitTransaction(callerAddress, op, { + ...options, + operation: "cancel_campaign_transfer", + }); + return txResult.txHash; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const errorCode = getContractErrorCode(err); + captureTransactionError( + "cancel_campaign_transfer", + campaignId, + error, + errorCode ? String(errorCode) : undefined, + ); + throw new Error(parseContractError(err)); + } +} + export async function depositRevenue( campaignId: number, amount: bigint,