diff --git a/apps/backoffice-tokenization/.env.example b/apps/backoffice-tokenization/.env.example index 978f842..e4465ea 100644 --- a/apps/backoffice-tokenization/.env.example +++ b/apps/backoffice-tokenization/.env.example @@ -1,8 +1,8 @@ # Trustless Work API Configuration NEXT_PUBLIC_API_KEY="" -# API URL -NEXT_PUBLIC_API_URL="http://localhost:3000/api" - # Server-side only (for contract deployment) -SOURCE_SECRET="" \ No newline at end of file +SOURCE_SECRET="" + +# Core API URL (NestJS backend) +NEXT_PUBLIC_CORE_API_URL="http://localhost:4000" \ No newline at end of file diff --git a/apps/backoffice-tokenization/services/wasm/soroban_token_contract.wasm b/apps/backoffice-tokenization/services/wasm/soroban_token_contract.wasm deleted file mode 100644 index 733c647..0000000 Binary files a/apps/backoffice-tokenization/services/wasm/soroban_token_contract.wasm and /dev/null differ diff --git a/apps/backoffice-tokenization/services/wasm/token_sale.wasm b/apps/backoffice-tokenization/services/wasm/token_sale.wasm deleted file mode 100644 index c543cd8..0000000 Binary files a/apps/backoffice-tokenization/services/wasm/token_sale.wasm and /dev/null differ diff --git a/apps/backoffice-tokenization/services/wasm/vault_contract.wasm b/apps/backoffice-tokenization/services/wasm/vault_contract.wasm deleted file mode 100644 index 0497db3..0000000 Binary files a/apps/backoffice-tokenization/services/wasm/vault_contract.wasm and /dev/null differ diff --git a/apps/backoffice-tokenization/src/app/api/deploy/route.ts b/apps/backoffice-tokenization/src/app/api/deploy/route.ts deleted file mode 100644 index f358a48..0000000 --- a/apps/backoffice-tokenization/src/app/api/deploy/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { NextResponse } from "next/server"; -import { SorobanClient } from "../../../lib/sorobanClient"; -import { deployTokenContracts } from "../../../lib/tokenDeploymentService"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; -const SOURCE_SECRET = process.env.SOURCE_SECRET || ""; - -export async function POST(request: Request) { - const data = await request.json(); - const { escrowContractId, tokenName, tokenSymbol } = data ?? {}; - - if (!escrowContractId || !tokenName || !tokenSymbol) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: "escrowContractId, tokenName, and tokenSymbol are required", - }), - { status: 400 }, - ); - } - - if (!SOURCE_SECRET) { - return new Response( - JSON.stringify({ - error: "Configuration Error", - details: "SOURCE_SECRET environment variable is not set. Please configure it in your .env.local file.", - }), - { status: 500 }, - ); - } - - try { - console.log("Creating SorobanClient..."); - const sorobanClient = new SorobanClient({ - rpcUrl: RPC_URL, - sourceSecret: SOURCE_SECRET, - }); - console.log(`SorobanClient created for account: ${sorobanClient.publicKey}`); - - // Verify account exists and has balance - try { - const StellarSDK = await import("@stellar/stellar-sdk"); - const server = new StellarSDK.rpc.Server(RPC_URL); - const account = await server.getAccount(sorobanClient.publicKey); - // Account object has balances property, but TypeScript types may not include it - const balances = (account as any).balances; - const balance = balances && Array.isArray(balances) && balances.length > 0 - ? balances[0]?.balance || "unknown" - : "unknown"; - console.log(`Account verified. Balance: ${balance}`); - } catch (accountError) { - console.warn("Could not verify account balance:", accountError); - // Continue anyway, the transaction will fail with a clearer error if account doesn't exist - } - - console.log("Starting token deployment..."); - const { tokenFactoryAddress, tokenSaleAddress } = - await deployTokenContracts(sorobanClient, { - escrowContractId, - tokenName, - tokenSymbol, - }); - - console.log("Token deployment completed successfully"); - console.log(`TokenFactory: ${tokenFactoryAddress}`); - console.log(`TokenSale: ${tokenSaleAddress}`); - - return NextResponse.json({ - success: true, - tokenFactoryAddress, - tokenSaleAddress, - }); - } catch (error) { - console.error("Deployment error:", error); - const errorMessage = error instanceof Error ? error.message : String(error); - - // Provide more helpful error messages - let userFriendlyMessage = errorMessage; - if (errorMessage.includes("timeout")) { - userFriendlyMessage = - "The deployment is taking longer than expected. " + - "This can happen when the Soroban network is busy. " + - "The transaction may still be processing. " + - "Please wait a few minutes and check the transaction status, or try again. " + - `Error details: ${errorMessage}`; - } else if (errorMessage.includes("insufficient")) { - userFriendlyMessage = - "Insufficient balance. Please ensure your account has enough XLM to pay for transaction fees. " + - `Error details: ${errorMessage}`; - } else { - const errorStr = errorMessage.toLowerCase(); - const isExistingContractError = - errorStr.includes("contract already exists") || - errorStr.includes("existingvalue") || - errorStr.includes("already deployed") || - (errorStr.includes("storage") && errorStr.includes("existing")) || - (errorStr.includes("hosterror") && errorStr.includes("storage") && errorStr.includes("existing")); - - if (isExistingContractError) { - userFriendlyMessage = - "Contracts are already deployed for this escrowContractId. " + - "To redeploy, you can either: " + - "1. Use a different escrowContractId, or " + - "2. Provide a 'deploymentId' parameter in your request (e.g., {\"deploymentId\": \"v2\"}) to create unique contract addresses. " + - `Error details: ${errorMessage}`; - } - } - - return new Response( - JSON.stringify({ - error: "Internal Server Error", - details: userFriendlyMessage, - }), - { status: 500 }, - ); - } -} diff --git a/apps/backoffice-tokenization/src/app/api/deploy/vault-contract/route.ts b/apps/backoffice-tokenization/src/app/api/deploy/vault-contract/route.ts deleted file mode 100644 index f67aa99..0000000 --- a/apps/backoffice-tokenization/src/app/api/deploy/vault-contract/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextResponse } from "next/server"; -import { SorobanClient } from "../../../../lib/sorobanClient"; -import { deployVaultContract } from "../../../../lib/vaultDeploymentService"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; -const SOURCE_SECRET = process.env.SOURCE_SECRET || ""; - -export async function POST(request: Request) { - const data = await request.json(); - const { admin, enabled, price, token, usdc } = data ?? {}; - - if (!admin || typeof enabled !== "boolean" || !price || !token || !usdc) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: - "admin, enabled (boolean), price, token, and usdc are required", - }), - { status: 400 }, - ); - } - - if (typeof price !== "number" && typeof price !== "string") { - return new Response( - JSON.stringify({ - error: "Invalid price", - details: "price must be a number or string", - }), - { status: 400 }, - ); - } - - if (!SOURCE_SECRET) { - return new Response( - JSON.stringify({ - error: "Configuration Error", - details: "SOURCE_SECRET environment variable is not set. Please configure it in your .env.local file.", - }), - { status: 500 }, - ); - } - - try { - const sorobanClient = new SorobanClient({ - rpcUrl: RPC_URL, - sourceSecret: SOURCE_SECRET, - }); - - const { vaultContractAddress } = await deployVaultContract(sorobanClient, { - admin, - enabled, - price, - token, - usdc, - }); - - return NextResponse.json({ - success: true, - vaultContractAddress, - }); - } catch (error) { - console.error("Vault deployment error:", error); - return new Response( - JSON.stringify({ - error: "Internal Server Error", - details: error instanceof Error ? error.message : String(error), - }), - { status: 500 }, - ); - } -} diff --git a/apps/backoffice-tokenization/src/app/api/helper/send-transaction/route.ts b/apps/backoffice-tokenization/src/app/api/helper/send-transaction/route.ts deleted file mode 100644 index 24824b7..0000000 --- a/apps/backoffice-tokenization/src/app/api/helper/send-transaction/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request) { - const { signedXdr } = await request.json(); - const server = new StellarSDK.Horizon.Server( - "https://horizon-testnet.stellar.org", - { - allowHttp: true, - }, - ); - - try { - const transaction = StellarSDK.TransactionBuilder.fromXDR( - signedXdr, - StellarSDK.Networks.TESTNET, - ); - - const response = await server.submitTransaction(transaction); - if (!response.successful) { - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.FAILED, - message: - "The transaction could not be sent to the Stellar network for some unknown reason. Please try again.", - }); - } - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.SUCCESS, - message: - "The transaction has been successfully sent to the Stellar network.", - hash: response.hash, - }); - } catch (error) { - console.error("Transaction submission error:", error); - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.FAILED, - message: - error instanceof Error - ? error.message - : "An unknown error occurred while submitting the transaction.", - }); - } -} diff --git a/apps/backoffice-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts b/apps/backoffice-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts deleted file mode 100644 index fec07a8..0000000 --- a/apps/backoffice-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; -import { extractContractError } from "../../../../lib/contractErrorHandler"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - console.log("POST /api/vault-contract/availability-for-exchange called"); - - const data = await request.json(); - const { vaultContractId, adminAddress, enabled } = data ?? {}; - - console.log("Request data:", { vaultContractId, adminAddress, enabled }); - - if (!vaultContractId || !adminAddress || typeof enabled !== "boolean") { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: - "vaultContractId, adminAddress, and enabled (boolean) are required", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - const sourceAccount = await server.getAccount(adminAddress); - - const contract = new StellarSDK.Contract(vaultContractId); - - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation( - contract.call( - "availability_for_exchange", - StellarSDK.nativeToScVal(new StellarSDK.Address(adminAddress), { - type: "address", - }), - StellarSDK.nativeToScVal(enabled, { type: "bool" }), - ), - ) - .setTimeout(300) - .build(); - - const preparedTransaction = await server.prepareTransaction(transaction); - const xdr = preparedTransaction.toXDR(); - - return NextResponse.json({ - success: true, - xdr, - message: - "Transaction built successfully. Sign with wallet and submit to network.", - }); - } catch (error) { - console.error("Availability for exchange transaction build error:", error); - const { message, details } = extractContractError(error); - return new Response( - JSON.stringify({ - error: message, - details: details, - }), - { status: 500 }, - ); - } -} diff --git a/apps/backoffice-tokenization/src/app/flow-roi/page.tsx b/apps/backoffice-tokenization/src/app/flow-roi/page.tsx new file mode 100644 index 0000000..cd7bcfb --- /dev/null +++ b/apps/backoffice-tokenization/src/app/flow-roi/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Suspense } from "react"; +import { RoiDashboard } from "@/features/flow-roi/RoiDashboard"; + +export default function RoiPage() { + return ( + + + + ); +} diff --git a/apps/backoffice-tokenization/src/app/flow-testing/[contractId]/page.tsx b/apps/backoffice-tokenization/src/app/flow-testing/[contractId]/page.tsx new file mode 100644 index 0000000..3244df7 --- /dev/null +++ b/apps/backoffice-tokenization/src/app/flow-testing/[contractId]/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { use, Suspense } from "react"; +import { EscrowDetail } from "@/features/flow-testing/EscrowDetail"; + +export default function EscrowDetailPage({ + params, +}: { + params: Promise<{ contractId: string }>; +}) { + const { contractId } = use(params); + + return ( + + + + ); +} diff --git a/apps/backoffice-tokenization/src/app/flow-testing/create/page.tsx b/apps/backoffice-tokenization/src/app/flow-testing/create/page.tsx new file mode 100644 index 0000000..4c1cf45 --- /dev/null +++ b/apps/backoffice-tokenization/src/app/flow-testing/create/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Suspense } from "react"; +import { CreateCampaignFlow } from "@/features/flow-testing/CreateCampaignFlow"; + +export default function CreateCampaignPage() { + return ( + + + + ); +} diff --git a/apps/backoffice-tokenization/src/app/flow-testing/page.tsx b/apps/backoffice-tokenization/src/app/flow-testing/page.tsx new file mode 100644 index 0000000..88bb04b --- /dev/null +++ b/apps/backoffice-tokenization/src/app/flow-testing/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Suspense } from "react"; +import { FlowTesting } from "@/features/flow-testing/FlowTesting"; + +export default function FlowTestingPage() { + return ( + + + + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/RoiDashboard.tsx b/apps/backoffice-tokenization/src/features/flow-roi/RoiDashboard.tsx new file mode 100644 index 0000000..41f4076 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/RoiDashboard.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@tokenization/ui/tabs"; +import { Loader2 } from "lucide-react"; +import { fetchCampaigns } from "@/features/flow-testing/services/campaign.service"; +import { CampaignRoiList } from "./components/CampaignRoiList"; +import type { Campaign } from "./types"; + +export function RoiDashboard() { + const { walletAddress } = useWalletContext(); + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadCampaigns = useCallback(() => { + setLoading(true); + setError(null); + fetchCampaigns() + .then((data) => setCampaigns(data as Campaign[])) + .catch(() => setError("Could not load campaigns.")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + loadCampaigns(); + }, [loadCampaigns]); + + if (!walletAddress) { + return ( +
+

+ Connect your wallet to continue +

+
+ ); + } + + const withVault = campaigns.filter((c) => !!c.vaultId); + const withoutVault = campaigns.filter((c) => !c.vaultId); + + return ( +
+
+
+

Return on Investment

+
+ + {loading && ( +
+ + Loading campaigns... +
+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && ( + + + All + With Vault + Without Vault + + + + + + + + + + + + + )} +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiCard.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiCard.tsx new file mode 100644 index 0000000..bc79547 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiCard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@tokenization/ui/button"; +import { truncate } from "@/features/flow-testing/constants"; +import { STATUS_LABELS, STATUS_COLORS } from "@/features/flow-testing/constants"; +import { CreateRoiDialog } from "./CreateRoiDialog"; +import { FundRoiDialog } from "./FundRoiDialog"; +import { VaultInfoPanel } from "./VaultInfoPanel"; +import { ToggleVaultButton } from "./ToggleVaultButton"; +import { useVaultInfo } from "../hooks/useVaultInfo"; +import type { Campaign } from "../types"; + +interface CampaignRoiCardProps { + campaign: Campaign; + index: number; + onUpdated: () => void; +} + +export function CampaignRoiCard({ + campaign, + index, + onUpdated, +}: CampaignRoiCardProps) { + const [createOpen, setCreateOpen] = useState(false); + const [fundOpen, setFundOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + const statusColor = + STATUS_COLORS[campaign.status] ?? + "text-muted-foreground border-muted-foreground/30"; + + const hasTokenFactory = !!campaign.tokenFactoryId; + const hasVault = !!campaign.vaultId; + + const { info: vaultInfo, loading: vaultLoading } = useVaultInfo( + campaign.vaultId ?? "", + refreshKey, + ); + + const handleRefresh = () => { + setRefreshKey((k) => k + 1); + onUpdated(); + }; + + return ( + <> +
+
+
+
+ + #{String(index + 1).padStart(3, "0")} + +

+ {campaign.name} +

+
+ {campaign.description && ( +

+ {campaign.description} +

+ )} +
+ + + {STATUS_LABELS[campaign.status] ?? campaign.status} + +
+ + {hasVault && campaign.vaultId ? ( +
+

+ Vault: {truncate(campaign.vaultId)} +

+ +
+ ) : ( +

No vault deployed

+ )} + +
+ {!hasVault && hasTokenFactory ? ( + + ) : null} + + {hasVault ? ( + <> + + + + ) : null} + + {!hasTokenFactory ? ( +

+ Token Factory required to create vault +

+ ) : null} +
+
+ + {hasTokenFactory && campaign.tokenFactoryId && !hasVault ? ( + { + onUpdated(); + }} + /> + ) : null} + + {hasVault && campaign.vaultId ? ( + + ) : null} + + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiList.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiList.tsx new file mode 100644 index 0000000..87b7d53 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/CampaignRoiList.tsx @@ -0,0 +1,33 @@ +import type { Campaign } from "../types"; +import { CampaignRoiCard } from "./CampaignRoiCard"; + +interface CampaignRoiListProps { + campaigns: Campaign[]; + onUpdated: () => void; +} + +export function CampaignRoiList({ + campaigns, + onUpdated, +}: CampaignRoiListProps) { + if (campaigns.length === 0) { + return ( +

+ No campaigns found. +

+ ); + } + + return ( +
+ {campaigns.map((c, i) => ( + + ))} +
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/CreateRoiDialog.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/CreateRoiDialog.tsx new file mode 100644 index 0000000..f622d3b --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/CreateRoiDialog.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@tokenization/ui/dialog"; +import { Button } from "@tokenization/ui/button"; +import { Input } from "@tokenization/ui/input"; +import { Label } from "@tokenization/ui/label"; +import { Loader2 } from "lucide-react"; +import { useCreateRoi } from "../hooks/useCreateRoi"; +import { toast } from "sonner"; + +interface CreateRoiDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + campaignId: string; + campaignName: string; + tokenFactoryId: string; + onCreated: (vaultId: string) => void; +} + +export function CreateRoiDialog({ + open, + onOpenChange, + campaignId, + campaignName, + tokenFactoryId, + onCreated, +}: CreateRoiDialogProps) { + const [roiPercentage, setRoiPercentage] = useState(""); + + const { execute, isSubmitting, error } = useCreateRoi({ + campaignId, + tokenFactoryId, + onSuccess: (vaultId) => { + toast.success("Vault deployed successfully"); + onCreated(vaultId); + onOpenChange(false); + setRoiPercentage(""); + }, + }); + + const multiplier = Number.isFinite(Number(roiPercentage)) + ? 1 + Number(roiPercentage) / 100 + : 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const pct = Number(roiPercentage); + if (!Number.isFinite(pct) || pct <= 0) return; + execute(pct); + }; + + return ( + + + + Create ROI — {campaignName} + +
+
+ + setRoiPercentage(e.target.value)} + disabled={isSubmitting} + autoComplete="off" + /> +

+ Multiplier:{" "} + + {Number.isFinite(multiplier) && multiplier > 0 + ? multiplier.toFixed(4) + : "—"} + +

+
+ +
+

+ Token Factory:{" "} + {tokenFactoryId} +

+
+ + {error ? ( +

{error}

+ ) : null} + + +
+
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/FundRoiDialog.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/FundRoiDialog.tsx new file mode 100644 index 0000000..c50b611 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/FundRoiDialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@tokenization/ui/dialog"; +import { Button } from "@tokenization/ui/button"; +import { Input } from "@tokenization/ui/input"; +import { Label } from "@tokenization/ui/label"; +import { Loader2 } from "lucide-react"; +import { useFundRoi } from "../hooks/useFundRoi"; +import { toast } from "sonner"; + +interface FundRoiDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + campaignName: string; + vaultId: string; + onFunded: () => void; +} + +export function FundRoiDialog({ + open, + onOpenChange, + campaignName, + vaultId, + onFunded, +}: FundRoiDialogProps) { + const [amount, setAmount] = useState(""); + + const { execute, isSubmitting, error } = useFundRoi({ + onSuccess: () => { + toast.success("Vault funded successfully"); + onFunded(); + onOpenChange(false); + setAmount(""); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const parsed = Number(amount); + if (!Number.isFinite(parsed) || parsed <= 0) return; + execute(vaultId, parsed); + }; + + return ( + + + + Fund ROI — {campaignName} + + Transfer USDC to the vault so investors can claim their returns. + + + +
+
+ + setAmount(e.target.value)} + disabled={isSubmitting} + autoComplete="off" + /> +
+ +

+ Vault:{" "} + {vaultId} +

+ + {error ? ( +

{error}

+ ) : null} + + +
+
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/ToggleVaultButton.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/ToggleVaultButton.tsx new file mode 100644 index 0000000..7211c24 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/ToggleVaultButton.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button } from "@tokenization/ui/button"; +import { Loader2, Power } from "lucide-react"; +import { useToggleVault } from "../hooks/useToggleVault"; +import { toast } from "sonner"; + +interface ToggleVaultButtonProps { + vaultId: string; + currentlyEnabled: boolean | null; + onToggled: () => void; +} + +export function ToggleVaultButton({ + vaultId, + currentlyEnabled, + onToggled, +}: ToggleVaultButtonProps) { + const { execute, isSubmitting, error } = useToggleVault({ + onSuccess: () => { + toast.success( + currentlyEnabled ? "Vault disabled" : "Vault enabled", + ); + onToggled(); + }, + }); + + const nextState = !currentlyEnabled; + + return ( + <> + + {error ? ( +

{error}

+ ) : null} + + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/components/VaultInfoPanel.tsx b/apps/backoffice-tokenization/src/features/flow-roi/components/VaultInfoPanel.tsx new file mode 100644 index 0000000..74de51f --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/components/VaultInfoPanel.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import type { VaultInfo } from "../types"; + +interface VaultInfoPanelProps { + info: VaultInfo | null; + loading: boolean; +} + +export function VaultInfoPanel({ info, loading }: VaultInfoPanelProps) { + if (loading) { + return ( +
+ + Loading vault... +
+ ); + } + + if (!info) { + return ( +

+ Vault info unavailable +

+ ); + } + + return ( +
+ + Status:{" "} + + {info.enabled ? "Enabled" : "Disabled"} + + + + ROI: {info.roiPercentage}% + + + USDC Balance:{" "} + {info.usdcBalance} + +
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/hooks/useCreateRoi.ts b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useCreateRoi.ts new file mode 100644 index 0000000..2a715e7 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useCreateRoi.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; +import { submitAndExtractAddress } from "@/features/flow-testing/services/soroban.service"; +import { + deployVault, + updateCampaignVaultId, +} from "../services/roi.service"; +import { USDC_TESTNET_ADDRESS } from "@/features/flow-testing/constants"; + +interface UseCreateRoiParams { + campaignId: string; + tokenFactoryId: string; + onSuccess?: (vaultContractId: string) => void; +} + +export function useCreateRoi({ + campaignId, + tokenFactoryId, + onSuccess, +}: UseCreateRoiParams) { + const { walletAddress } = useWalletContext(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const execute = async (roiPercentage: number) => { + if (!walletAddress) { + setError("Wallet not connected"); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const { unsignedXdr } = await deployVault({ + admin: walletAddress, + enabled: false, + roiPercentage, + token: tokenFactoryId, + usdc: USDC_TESTNET_ADDRESS, + callerPublicKey: walletAddress, + }); + + const signedXdr = await signTransaction({ + unsignedTransaction: unsignedXdr, + address: walletAddress, + }); + + const vaultContractId = await submitAndExtractAddress(signedXdr); + + if (!vaultContractId) { + throw new Error("Could not extract vault contract address from transaction"); + } + + await updateCampaignVaultId(campaignId, vaultContractId); + + onSuccess?.(vaultContractId); + } catch (e) { + const message = e instanceof Error ? e.message : "Unexpected error"; + setError(message); + } finally { + setIsSubmitting(false); + } + }; + + return { execute, isSubmitting, error }; +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/hooks/useFundRoi.ts b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useFundRoi.ts new file mode 100644 index 0000000..62069e7 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useFundRoi.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; +import { submitAndExtractAddress } from "@/features/flow-testing/services/soroban.service"; +import { buildUsdcTransferXdr } from "../services/transfer.service"; + +interface UseFundRoiParams { + onSuccess?: () => void; +} + +export function useFundRoi({ onSuccess }: UseFundRoiParams = {}) { + const { walletAddress } = useWalletContext(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const execute = async (vaultContractId: string, amount: number) => { + if (!walletAddress) { + setError("Wallet not connected"); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const unsignedXdr = await buildUsdcTransferXdr({ + from: walletAddress, + to: vaultContractId, + amount, + }); + + const signedXdr = await signTransaction({ + unsignedTransaction: unsignedXdr, + address: walletAddress, + }); + + await submitAndExtractAddress(signedXdr); + + onSuccess?.(); + } catch (e) { + const message = e instanceof Error ? e.message : "Unexpected error"; + setError(message); + } finally { + setIsSubmitting(false); + } + }; + + return { execute, isSubmitting, error }; +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/hooks/useToggleVault.ts b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useToggleVault.ts new file mode 100644 index 0000000..dfb9ab8 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useToggleVault.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; +import { submitAndExtractAddress } from "@/features/flow-testing/services/soroban.service"; +import { enableVault } from "../services/roi.service"; + +interface UseToggleVaultParams { + onSuccess?: () => void; +} + +export function useToggleVault({ onSuccess }: UseToggleVaultParams = {}) { + const { walletAddress } = useWalletContext(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const execute = async (vaultContractId: string, enabled: boolean) => { + if (!walletAddress) { + setError("Wallet not connected"); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const { unsignedXdr } = await enableVault({ + contractId: vaultContractId, + admin: walletAddress, + enabled, + callerPublicKey: walletAddress, + }); + + const signedXdr = await signTransaction({ + unsignedTransaction: unsignedXdr, + address: walletAddress, + }); + + await submitAndExtractAddress(signedXdr); + + onSuccess?.(); + } catch (e) { + const message = e instanceof Error ? e.message : "Unexpected error"; + setError(message); + } finally { + setIsSubmitting(false); + } + }; + + return { execute, isSubmitting, error }; +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/hooks/useVaultInfo.ts b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useVaultInfo.ts new file mode 100644 index 0000000..357d660 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/hooks/useVaultInfo.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { + getVaultIsEnabled, + getVaultUsdcBalance, + getVaultRoiPercentage, +} from "../services/roi.service"; +import type { VaultInfo } from "../types"; + +export function useVaultInfo(vaultId: string, refreshKey?: number) { + const { walletAddress } = useWalletContext(); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!walletAddress || !vaultId) { + setLoading(false); + return; + } + + setLoading(true); + + Promise.all([ + getVaultIsEnabled(vaultId, walletAddress).catch(() => null), + getVaultUsdcBalance(vaultId, walletAddress).catch(() => null), + getVaultRoiPercentage(vaultId, walletAddress).catch(() => null), + ]) + .then(([enabledRes, balanceRes, roiRes]) => { + if (enabledRes || balanceRes || roiRes) { + setInfo({ + enabled: enabledRes?.enabled ?? false, + usdcBalance: balanceRes?.balance ?? "—", + roiPercentage: roiRes?.roiPercentage ?? "—", + }); + } + }) + .finally(() => setLoading(false)); + }, [vaultId, walletAddress, refreshKey]); + + return { info, loading }; +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/services/roi.service.ts b/apps/backoffice-tokenization/src/features/flow-roi/services/roi.service.ts new file mode 100644 index 0000000..08c10cf --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/services/roi.service.ts @@ -0,0 +1,105 @@ +import { CORE_API } from "@/features/flow-testing/constants"; + +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${CORE_API}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { message?: string }).message ?? `Error ${res.status} on ${path}`, + ); + } + return res.json() as Promise; +} + +async function patch(path: string, body: unknown): Promise { + const res = await fetch(`${CORE_API}${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { message?: string }).message ?? `Error ${res.status} on ${path}`, + ); + } + return res.json() as Promise; +} + +async function get(path: string): Promise { + const res = await fetch(`${CORE_API}${path}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { message?: string }).message ?? `Error ${res.status} on ${path}`, + ); + } + return res.json() as Promise; +} + +export async function deployVault(params: { + admin: string; + enabled: boolean; + roiPercentage: number; + token: string; + usdc: string; + callerPublicKey: string; +}): Promise<{ unsignedXdr: string }> { + return post("/deploy/vault", params); +} + +export async function enableVault(params: { + contractId: string; + admin: string; + enabled: boolean; + callerPublicKey: string; +}): Promise<{ unsignedXdr: string }> { + return post("/vault/availability-for-exchange", params); +} + +export async function updateCampaignVaultId( + campaignId: string, + vaultId: string, +): Promise { + return patch(`/campaigns/${campaignId}`, { vaultId }); +} + +export async function getVaultUsdcBalance( + contractId: string, + callerPublicKey: string, +): Promise<{ balance: string }> { + return get( + `/vault/usdc-balance?contractId=${contractId}&callerPublicKey=${callerPublicKey}`, + ); +} + +export async function getVaultIsEnabled( + contractId: string, + callerPublicKey: string, +): Promise<{ enabled: boolean }> { + return get( + `/vault/is-enabled?contractId=${contractId}&callerPublicKey=${callerPublicKey}`, + ); +} + +export async function getVaultRoiPercentage( + contractId: string, + callerPublicKey: string, +): Promise<{ roiPercentage: string }> { + return get( + `/vault/roi-percentage?contractId=${contractId}&callerPublicKey=${callerPublicKey}`, + ); +} + +export async function getVaultOverview( + contractId: string, + callerPublicKey: string, +): Promise { + return get( + `/vault/overview?contractId=${contractId}&callerPublicKey=${callerPublicKey}`, + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/services/transfer.service.ts b/apps/backoffice-tokenization/src/features/flow-roi/services/transfer.service.ts new file mode 100644 index 0000000..0fa8f88 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/services/transfer.service.ts @@ -0,0 +1,51 @@ +import { + rpc, + TransactionBuilder, + Networks, + Address, + nativeToScVal, + Operation, + BASE_FEE, +} from "@stellar/stellar-sdk"; + +const SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; +const USDC_CONTRACT = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +export async function buildUsdcTransferXdr(params: { + from: string; + to: string; + amount: number; +}): Promise { + const server = new rpc.Server(SOROBAN_RPC_URL); + const account = await server.getAccount(params.from); + + const stroops = BigInt(Math.round(params.amount * 10_000_000)); + + const transferOp = Operation.invokeContractFunction({ + contract: USDC_CONTRACT, + function: "transfer", + args: [ + new Address(params.from).toScVal(), + new Address(params.to).toScVal(), + nativeToScVal(stroops, { type: "i128" }), + ], + }); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(transferOp) + .setTimeout(300) + .build(); + + const simulated = await server.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(simulated)) { + throw new Error(`Simulation failed: ${simulated.error}`); + } + + const prepared = rpc.assembleTransaction(tx, simulated).build(); + + return prepared.toXDR(); +} diff --git a/apps/backoffice-tokenization/src/features/flow-roi/types.ts b/apps/backoffice-tokenization/src/features/flow-roi/types.ts new file mode 100644 index 0000000..ce75ec1 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-roi/types.ts @@ -0,0 +1,17 @@ +export interface Campaign { + id: string; + name: string; + description?: string; + status: string; + issuerAddress: string; + escrowId: string; + tokenFactoryId: string | null; + tokenSaleId: string | null; + vaultId: string | null; +} + +export interface VaultInfo { + enabled: boolean; + usdcBalance: string; + roiPercentage: string; +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/AddLoanDialog.tsx b/apps/backoffice-tokenization/src/features/flow-testing/AddLoanDialog.tsx new file mode 100644 index 0000000..7208a2b --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/AddLoanDialog.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@tokenization/ui/button"; +import { Input } from "@tokenization/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@tokenization/ui/dialog"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@tokenization/ui/form"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { useEscrowsMutations } from "@tokenization/tw-blocks-shared/src/tanstack/useEscrowsMutations"; +import { + ErrorResponse, + handleError, +} from "@tokenization/tw-blocks-shared/src/handle-errors/handle"; +import { + GetEscrowsFromIndexerResponse, + UpdateMultiReleaseEscrowPayload, + MultiReleaseMilestone, +} from "@trustless-work/escrow/types"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +const addLoanSchema = z.object({ + description: z.string().min(1, "La descripcion es requerida"), + amount: z.coerce.number().positive("Debe ser mayor a 0"), +}); + +type AddLoanFormValues = z.infer; + +interface AddLoanDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + escrow: GetEscrowsFromIndexerResponse; + onSuccess: () => void; +} + +export function AddLoanDialog({ + open, + onOpenChange, + escrow, + onSuccess, +}: AddLoanDialogProps) { + const { walletAddress } = useWalletContext(); + const { updateEscrow } = useEscrowsMutations(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(addLoanSchema), + defaultValues: { + description: "", + amount: "" as unknown as number, + }, + mode: "onChange", + }); + + const handleSubmit = form.handleSubmit(async (data) => { + if (!walletAddress || !escrow.contractId) return; + + setIsSubmitting(true); + try { + const existingMilestones = ( + (escrow.milestones || []) as MultiReleaseMilestone[] + ).map((m, index) => ({ + description: m.description, + amount: typeof m.amount === "string" ? Number(m.amount) : m.amount, + receiver: + (m as MultiReleaseMilestone & { receiver?: string }).receiver || "", + evidence: escrow.milestones?.[index]?.evidence || "", + status: escrow.milestones?.[index]?.status || "", + })); + + const newMilestone = { + description: data.description, + amount: data.amount, + receiver: walletAddress, + evidence: "", + status: "", + }; + + const payload: UpdateMultiReleaseEscrowPayload = { + contractId: escrow.contractId, + signer: walletAddress, + escrow: { + engagementId: escrow.engagementId, + title: escrow.title, + description: escrow.description, + platformFee: + typeof escrow.platformFee === "string" + ? Number(escrow.platformFee) + : escrow.platformFee, + trustline: { + address: escrow.trustline?.address || "", + symbol: "USDC", + }, + roles: { + approver: escrow.roles?.approver || "", + serviceProvider: escrow.roles?.serviceProvider || "", + platformAddress: escrow.roles?.platformAddress || "", + releaseSigner: escrow.roles?.releaseSigner || "", + disputeResolver: escrow.roles?.disputeResolver || "", + }, + milestones: [...existingMilestones, newMilestone], + }, + }; + + await updateEscrow.mutateAsync({ + payload, + type: "multi-release", + address: walletAddress, + }); + + toast.success("Prestamo agregado exitosamente"); + form.reset(); + onOpenChange(false); + onSuccess(); + } catch (error) { + toast.error(handleError(error as ErrorResponse).message); + } finally { + setIsSubmitting(false); + } + }); + + return ( + + + + Nuevo Prestamo + +
+ + ( + + + Descripcion* + + + + + + + )} + /> + + ( + + + Monto (USDC)* + + + + + + + )} + /> + +
+ Wallet + + {walletAddress} + +
+ + + + +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/CreateCampaignFlow.tsx b/apps/backoffice-tokenization/src/features/flow-testing/CreateCampaignFlow.tsx new file mode 100644 index 0000000..af19d18 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/CreateCampaignFlow.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { StepCampaignForm } from "./steps/StepCampaignForm"; +import { StepInitializeEscrow } from "./steps/StepInitializeEscrow"; +import { StepTokenizeEscrow } from "./steps/StepTokenizeEscrow"; +import { FlowStepper } from "./components/FlowStepper"; +import { FLOW_STEPS } from "./constants"; + +export function CreateCampaignFlow() { + const [currentStep, setCurrentStep] = useState(0); + const { walletAddress } = useWalletContext(); + + if (!walletAddress) { + return ( +
+

+ Conecta tu wallet para continuar +

+
+ ); + } + + return ( +
+
+ + + {currentStep === 0 && ( + setCurrentStep(1)} /> + )} + {currentStep === 1 && ( + setCurrentStep(2)} + /> + )} + {currentStep === 2 && } +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/EscrowDetail.tsx b/apps/backoffice-tokenization/src/features/flow-testing/EscrowDetail.tsx new file mode 100644 index 0000000..80d8aa0 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/EscrowDetail.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Button } from "@tokenization/ui/button"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { useEscrowsMutations } from "@tokenization/tw-blocks-shared/src/tanstack/useEscrowsMutations"; +import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; +import { + GetEscrowsFromIndexerResponse, + MultiReleaseMilestone, + MultiReleaseReleaseFundsPayload, + ApproveMilestonePayload, +} from "@trustless-work/escrow/types"; +import { + ErrorResponse, + handleError, +} from "@tokenization/tw-blocks-shared/src/handle-errors/handle"; +import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider"; +import { useChangeMilestoneStatus } from "@tokenization/tw-blocks-shared/src/escrows/single-multi-release/change-milestone-status/dialog/useChangeMilestoneStatus"; +import { toast } from "sonner"; +import { Loader2, ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { EscrowHeader } from "./components/EscrowHeader"; +import { EscrowLoansCard } from "./components/EscrowLoansCard"; +import { AddLoanDialog } from "./AddLoanDialog"; + +interface EscrowDetailProps { + contractId: string; +} + +export function EscrowDetail({ contractId }: EscrowDetailProps) { + const { walletAddress } = useWalletContext(); + const { releaseFunds, approveMilestone } = useEscrowsMutations(); + const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); + const { selectedEscrow, setSelectedEscrow } = useEscrowContext(); + const router = useRouter(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [releasingIndex, setReleasingIndex] = useState(null); + const [approvingIndex, setApprovingIndex] = useState(null); + const [addLoanOpen, setAddLoanOpen] = useState(false); + + const changeMilestoneStatusHook = useChangeMilestoneStatus(); + + const fetchEscrow = useCallback(async () => { + setLoading(true); + setError(null); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await getEscrowByContractIds({ + contractIds: [contractId], + validateOnChain: true, + })) as any; + + if (!data || !data[0]) { + throw new Error("Escrow no encontrado"); + } + setSelectedEscrow(data[0]); + } catch (err) { + const { message } = handleError(err as ErrorResponse); + setError(message); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractId]); + + useEffect(() => { + fetchEscrow(); + }, [fetchEscrow]); + + const handleRelease = async (milestoneIndex: number) => { + if (!walletAddress || !selectedEscrow?.contractId) return; + + setReleasingIndex(milestoneIndex); + try { + const payload: MultiReleaseReleaseFundsPayload = { + contractId: selectedEscrow.contractId, + releaseSigner: walletAddress, + milestoneIndex: String(milestoneIndex), + }; + + await releaseFunds.mutateAsync({ + payload, + type: "multi-release", + address: walletAddress, + }); + + toast.success(`Fondos del prestamo ${milestoneIndex + 1} liberados`); + await fetchEscrow(); + } catch (err) { + toast.error(handleError(err as ErrorResponse).message); + } finally { + setReleasingIndex(null); + } + }; + + const handleApprove = async (milestoneIndex: number) => { + if (!walletAddress || !selectedEscrow?.contractId) return; + + setApprovingIndex(milestoneIndex); + try { + const payload: ApproveMilestonePayload = { + contractId: selectedEscrow.contractId, + milestoneIndex: String(milestoneIndex), + approver: walletAddress, + }; + + await approveMilestone.mutateAsync({ + payload, + type: "multi-release", + address: walletAddress, + }); + + toast.success(`Prestamo ${milestoneIndex + 1} aprobado`); + await fetchEscrow(); + } catch (err) { + toast.error(handleError(err as ErrorResponse).message); + } finally { + setApprovingIndex(null); + } + }; + + const handleChangeStatusClick = (milestoneIndex: number) => { + changeMilestoneStatusHook.form.setValue( + "milestoneIndex", + String(milestoneIndex), + ); + }; + + if (!walletAddress) { + return ( +
+

+ Conecta tu wallet para continuar +

+
+ ); + } + + if (loading) { + return ( +
+ +

+ Cargando escrow... +

+
+ ); + } + + if (error || !selectedEscrow) { + return ( +
+

+ {error || "Escrow no encontrado"} +

+ +
+ ); + } + + const milestones = (selectedEscrow.milestones || + []) as MultiReleaseMilestone[]; + const escrowBalance = Number(selectedEscrow.balance || 0); + + return ( +
+
+ router.push("/flow-testing")} + /> + + setAddLoanOpen(true)} + changeStatusForm={changeMilestoneStatusHook.form} + changeStatusSubmit={changeMilestoneStatusHook.handleSubmit} + changeStatusSubmitting={changeMilestoneStatusHook.isSubmitting} + /> + + +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/FlowTesting.tsx b/apps/backoffice-tokenization/src/features/flow-testing/FlowTesting.tsx new file mode 100644 index 0000000..e76425f --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/FlowTesting.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { Button } from "@tokenization/ui/button"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@tokenization/ui/tabs"; +import { Loader2, Plus } from "lucide-react"; +import { CampaignList } from "./components/CampaignList"; +import { fetchCampaigns } from "./services/campaign.service"; +import { + IN_PROGRESS_STATUSES, + INACTIVE_STATUSES, +} from "./constants"; +import type { Campaign } from "./types"; + +export function FlowTesting() { + const { walletAddress } = useWalletContext(); + const router = useRouter(); + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchCampaigns() + .then((data) => setCampaigns(data as Campaign[])) + .catch(() => + setError("No se pudieron cargar las campañas."), + ) + .finally(() => setLoading(false)); + }, []); + + if (!walletAddress) { + return ( +
+

+ Conecta tu wallet para continuar +

+
+ ); + } + + const inProgress = campaigns.filter((c) => + IN_PROGRESS_STATUSES.includes(c.status), + ); + const inactive = campaigns.filter((c) => + INACTIVE_STATUSES.includes(c.status), + ); + + return ( +
+
+
+

Campañas

+ +
+ + {loading && ( +
+ + Cargando campañas... +
+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && ( + + + All + + In Progress + + Inactive + + + + + + + + + + + + + )} +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignCard.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignCard.tsx new file mode 100644 index 0000000..9e9101b --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignCard.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button } from "@tokenization/ui/button"; +import { ExternalLink } from "lucide-react"; +import { + STATUS_LABELS, + STATUS_COLORS, + truncate, +} from "../constants"; +import type { Campaign } from "../types"; + +interface CampaignCardProps { + campaign: Campaign; + index: number; +} + +export function CampaignCard({ + campaign, + index, +}: CampaignCardProps) { + const router = useRouter(); + const statusColor = + STATUS_COLORS[campaign.status] ?? + "text-muted-foreground border-muted-foreground/30"; + + return ( +
+
+
+
+ + #{String(index + 1).padStart(3, "0")} + +

+ {campaign.name} +

+
+ {campaign.description && ( +

+ {campaign.description} +

+ )} +
+ +
+ + {STATUS_LABELS[campaign.status] ?? campaign.status} + + {campaign.escrowId && ( + + )} +
+
+ +
+

+ {campaign.escrowId + ? truncate(campaign.escrowId) + : "Sin escrow"} +

+ +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignList.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignList.tsx new file mode 100644 index 0000000..902b7a0 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/CampaignList.tsx @@ -0,0 +1,24 @@ +import type { Campaign } from "../types"; +import { CampaignCard } from "./CampaignCard"; + +interface CampaignListProps { + campaigns: Campaign[]; +} + +export function CampaignList({ campaigns }: CampaignListProps) { + if (campaigns.length === 0) { + return ( +

+ No hay campañas en esta categoría. +

+ ); + } + + return ( +
+ {campaigns.map((c, i) => ( + + ))} +
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowHeader.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowHeader.tsx new file mode 100644 index 0000000..656c9bb --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowHeader.tsx @@ -0,0 +1,31 @@ +import { Button } from "@tokenization/ui/button"; +import { ArrowLeft } from "lucide-react"; + +interface EscrowHeaderProps { + title: string; + contractId: string; + onBack: () => void; +} + +export function EscrowHeader({ title, contractId, onBack }: EscrowHeaderProps) { + return ( + <> +
+ +

Campanas

+
+
+

+ {title} — {contractId} +

+
+ + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowLoansCard.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowLoansCard.tsx new file mode 100644 index 0000000..b59a417 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/EscrowLoansCard.tsx @@ -0,0 +1,68 @@ +import { Button } from "@tokenization/ui/button"; +import { MultiReleaseMilestone } from "@trustless-work/escrow/types"; +import { MilestonesList } from "./MilestonesList"; +import { UseFormReturn } from "react-hook-form"; + +interface EscrowLoansCardProps { + milestones: MultiReleaseMilestone[]; + escrowBalance: number; + approvingIndex: number | null; + releasingIndex: number | null; + onApprove: (index: number) => void; + onRelease: (index: number) => void; + onChangeStatusClick: (index: number) => void; + onAddLoan: () => void; + changeStatusForm: UseFormReturn<{ + milestoneIndex: string; + status: string; + evidence?: string; + }>; + changeStatusSubmit: (e?: React.BaseSyntheticEvent) => void; + changeStatusSubmitting: boolean; +} + +export function EscrowLoansCard({ + milestones, + escrowBalance, + approvingIndex, + releasingIndex, + onApprove, + onRelease, + onChangeStatusClick, + onAddLoan, + changeStatusForm, + changeStatusSubmit, + changeStatusSubmitting, +}: EscrowLoansCardProps) { + return ( +
+
+

+ Gestionar Prestamos +

+

+ Balance: USDC {escrowBalance} +

+ +
+ +
+ + +
+
+ ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/FlowStepper.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/FlowStepper.tsx new file mode 100644 index 0000000..47a01f8 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/FlowStepper.tsx @@ -0,0 +1,64 @@ +import { Check } from "lucide-react"; + +interface FlowStepperProps { + steps: { label: string }[]; + currentStep: number; +} + +export function FlowStepper({ + steps, + currentStep, +}: FlowStepperProps) { + return ( + + ); +} diff --git a/apps/backoffice-tokenization/src/features/flow-testing/components/MilestoneRow.tsx b/apps/backoffice-tokenization/src/features/flow-testing/components/MilestoneRow.tsx new file mode 100644 index 0000000..5328f54 --- /dev/null +++ b/apps/backoffice-tokenization/src/features/flow-testing/components/MilestoneRow.tsx @@ -0,0 +1,191 @@ +import { Button } from "@tokenization/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@tokenization/ui/dialog"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@tokenization/ui/form"; +import { Input } from "@tokenization/ui/input"; +import { Textarea } from "@tokenization/ui/textarea"; +import { Loader2 } from "lucide-react"; +import { formatAddress } from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; +import { UseFormReturn } from "react-hook-form"; + +interface MilestoneRowProps { + index: number; + description: string; + receiver: string; + amount: number; + status?: string; + isApproved: boolean; + isReleased: boolean; + insufficientFunds: boolean; + approvingIndex: number | null; + releasingIndex: number | null; + onApprove: (index: number) => void; + onRelease: (index: number) => void; + onChangeStatusClick: (index: number) => void; + changeStatusForm: UseFormReturn<{ + milestoneIndex: string; + status: string; + evidence?: string; + }>; + changeStatusSubmit: (e?: React.BaseSyntheticEvent) => void; + changeStatusSubmitting: boolean; +} + +export function MilestoneRow({ + index, + description, + receiver, + amount, + status, + isApproved, + isReleased, + insufficientFunds, + approvingIndex, + releasingIndex, + onApprove, + onRelease, + onChangeStatusClick, + changeStatusForm, + changeStatusSubmit, + changeStatusSubmitting, +}: MilestoneRowProps) { + return ( +
+
+ {description} + + {formatAddress(receiver)} + + {status && ( + + Estado: {status} + + )} +
+ USDC {amount} +
+ {/* Change Milestone Status */} + + + + + + + Cambiar estado del prestamo + +
+ + ( + + + Estado + * + + + + + + + )} + /> + ( + + Evidencia + +