Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 5 additions & 134 deletions apps/investor-tokenization/src/app/my-investments/page.tsx
Original file line number Diff line number Diff line change
@@ -1,141 +1,12 @@
"use client";

import { useState, useMemo, useCallback } from "react";
import { SectionTitle } from "@/components/shared/section-title";
import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar";
import { CampaignList } from "@/features/roi/components/campaign-list";
import type { Campaign, CampaignStatus } from "@/features/roi/types/campaign.types";
import { useUserInvestments } from "@/features/investments/hooks/useUserInvestments.hook";
import type { InvestmentFromApi } from "@/features/investments/services/investment.service";
import { ClaimROIService } from "@/features/claim-roi/services/claim.service";
import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
import { SendTransactionService } from "@/lib/sendTransactionService";
import { toastSuccessWithTx } from "@/lib/toastWithTx";
import { toast } from "sonner";

function toCampaign(inv: InvestmentFromApi): Campaign {
return {
id: inv.campaign.id,
title: inv.campaign.name,
description: inv.campaign.description ?? "",
status: inv.campaign.status as CampaignStatus,
loansCompleted: 0,
investedAmount: Number(inv.usdcAmount),
currency: "USDC",
vaultId: inv.campaign.vaultId ?? null,
escrowId: inv.campaign.escrowId,
poolSize: Number(inv.campaign.poolSize),
};
}

function aggregateByCampaign(investments: InvestmentFromApi[]): Campaign[] {
const map = new Map<string, Campaign>();
for (const inv of investments) {
const existing = map.get(inv.campaign.id);
if (existing) {
existing.investedAmount += Number(inv.usdcAmount);
} else {
map.set(inv.campaign.id, toCampaign(inv));
}
}
return Array.from(map.values());
}
import { InvestmentsView } from "@/features/investments/InvestmentsView";
import { WalletGate } from "@/components/shared/WalletGate";

export default function MyInvestmentsPage() {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<CampaignStatus | "all">("all");
const { data: investments, isLoading } = useUserInvestments();
const { walletAddress } = useWalletContext();

const campaigns = useMemo(
() => aggregateByCampaign(investments ?? []),
[investments],
);

const filteredCampaigns = useMemo(() => {
return campaigns.filter((c) => {
const matchesStatus = filter === "all" || c.status === filter;
const matchesSearch =
search.trim() === "" ||
c.title.toLowerCase().includes(search.toLowerCase()) ||
c.description.toLowerCase().includes(search.toLowerCase());
return matchesStatus && matchesSearch;
});
}, [campaigns, search, filter]);

const handleClaimRoi = useCallback(
async (campaignId: string) => {
const campaign = campaigns.find((c) => c.id === campaignId);

if (!campaign?.vaultId) {
toast.error("Vault contract not available for this campaign.");
return;
}

if (!walletAddress) {
toast.error("Please connect your wallet to claim ROI.");
return;
}

try {
const svc = new ClaimROIService();
const claimResponse = await svc.claimROI({
vaultContractId: campaign.vaultId,
beneficiaryAddress: walletAddress,
});

if (!claimResponse?.success || !claimResponse?.xdr) {
throw new Error(
claimResponse?.message ?? "Failed to build claim transaction.",
);
}

const signedTxXdr = await signTransaction({
unsignedTransaction: claimResponse.xdr,
address: walletAddress,
});

const sender = new SendTransactionService({
baseURL: process.env.NEXT_PUBLIC_CORE_API_URL,
apiKey: process.env.NEXT_PUBLIC_INVESTORS_API_KEY,
});
const submitResponse = await sender.sendTransaction({
signedXdr: signedTxXdr,
});

if (submitResponse.status !== "SUCCESS") {
throw new Error(
submitResponse.message ?? "Transaction submission failed.",
);
}

toastSuccessWithTx("ROI claimed successfully!", submitResponse.hash);
} catch (e) {
const msg = e instanceof Error ? e.message : "Unexpected error while claiming ROI.";
toast.error(msg);
}
},
[campaigns, walletAddress],
);

return (
<div className="flex flex-col gap-6">
<SectionTitle
title="My Investments"
description="Track your active investments and claim your returns."
/>
<CampaignToolbar
onSearchChange={setSearch}
onFilterChange={setFilter}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-8">
Loading your investments...
</p>
) : (
<CampaignList campaigns={filteredCampaigns} onClaimRoi={handleClaimRoi} />
)}
</div>
<WalletGate>
<InvestmentsView />
</WalletGate>
);
}
54 changes: 54 additions & 0 deletions apps/investor-tokenization/src/components/shared/WalletGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
import { useWallet } from "@tokenization/tw-blocks-shared/src/wallet-kit/useWallet";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@tokenization/ui/card";
import { Button } from "@tokenization/ui/button";
import { Wallet } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";

type WalletGateProps = {
children: ReactNode;
};

/**
* Protege el contenido: si no hay wallet conectada, muestra una tarjeta
* para conectar en lugar del contenido. Usa Card (no Dialog) para evitar
* conflictos de z-index con el modal del wallet kit.
*/
export function WalletGate({ children }: WalletGateProps) {
const { walletAddress } = useWalletContext();
const { handleConnect } = useWallet();
const router = useRouter();

if (walletAddress) {
return <>{children}</>;
}

return (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="size-5" />
Conecta tu billetera
</CardTitle>
<CardDescription>
Necesitas conectar tu billetera para ver tus inversiones y acceder a
esta sección.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end gap-2">
<Button variant="outline" onClick={() => router.push("/campaigns")}>
Volver
</Button>
<Button onClick={handleConnect} size="lg">
<Wallet className="size-4 mr-2" />
Conectar Billetera
</Button>
</CardContent>
</Card>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const InvestmentsView = () => {
data: investments,
isLoading,
isError,
error,
} = useUserInvestments();

if (!walletAddress) {
Expand All @@ -37,6 +36,10 @@ export const InvestmentsView = () => {
);
}

// Sin backend de investments por wallet: mostrar vista normal con lista vacía
// en lugar de error (401 o endpoint inexistente)
const investmentList = isError ? [] : (investments ?? []);

Comment on lines +39 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t convert every fetch failure into “No Investments Yet.”

Line 41 currently treats all errors as empty data. That will hide genuine backend/runtime failures (e.g., 5xx/network) and mislead users with a false empty state. Scope this fallback to expected temporary cases (e.g., 401/404) and keep an explicit error path for everything else.

Scoped fallback example
   const {
     data: investments,
     isLoading,
     isError,
+    error,
   } = useUserInvestments();

-  // Sin backend de investments por wallet: mostrar vista normal con lista vacía
-  // en lugar de error (401 o endpoint inexistente)
-  const investmentList = isError ? [] : (investments ?? []);
+  const getStatusCode = (err: unknown): number | null => {
+    if (typeof err !== "object" || err === null || !("response" in err)) {
+      return null;
+    }
+    const response = (err as { response?: { status?: number } }).response;
+    return typeof response?.status === "number" ? response.status : null;
+  };
+
+  const statusCode = getStatusCode(error);
+  const treatAsEmptyState = statusCode === 401 || statusCode === 404;
+  const investmentList = treatAsEmptyState ? [] : (investments ?? []);

   if (isLoading) {
     return (
       <div className="container mx-auto px-4 py-8">
@@
     );
   }
+
+  if (isError && !treatAsEmptyState) {
+    return (
+      <div className="container mx-auto px-4 py-8">
+        <div className="max-w-2xl mx-auto text-center space-y-6">
+          <h2 className="text-2xl font-bold">My Investments</h2>
+          <p className="text-muted-foreground">
+            We couldn&apos;t load your investments right now. Please try again.
+          </p>
+        </div>
+      </div>
+    );
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/investor-tokenization/src/features/investments/InvestmentsView.tsx`
around lines 39 - 42, The current line unconditionally maps any fetch failure to
an empty list via investmentList = isError ? [] : (investments ?? []); instead
scope that fallback to only expected statuses (e.g., 401 or 404) by using the
actual error/status returned by your fetch hook (replace or augment isError with
the error object/status from the hook) and compute investmentList only when
error.status is 401 or 404; otherwise preserve the error state so the component
can render an explicit error UI for other failures. Update the code that renders
errors to show the real error when isError is true and error.status is not in
[401,404], and keep the empty-list fallback only for those specific statuses
(referencing investmentList, isError, investments and the hook’s error/status
field).

if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
Expand All @@ -48,21 +51,6 @@ export const InvestmentsView = () => {
);
}

if (isError) {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto text-center space-y-6">
<h2 className="text-2xl font-bold">My Investments</h2>
<p className="text-destructive">
Error loading investments: {error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
</div>
);
}

const investmentList = investments ?? [];

const totalInvested = React.useMemo(() => {
return investmentList.reduce((sum, inv) => sum + Number(inv.usdcAmount), 0);
}, [investmentList]);
Expand Down