diff --git a/CLAUDE.md b/CLAUDE.md index 03bae68..eafba73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,5 +78,5 @@ Four Soroban contracts sharing `soroban-sdk 23.1.1`. Build with `cargo build --r ## Environment Variables Each app has a `.env.example`. Key vars: -- **Frontend apps**: `NEXT_PUBLIC_API_KEY`, `NEXT_PUBLIC_API_URL`, `SOURCE_SECRET` -- **Core**: `DATABASE_URL`, `PORT`, `SOURCE_SECRET`, `SOROBAN_RPC_URL`, `*_WASM_HASH` (contract hashes) +- **Frontend apps**: `NEXT_PUBLIC_API_KEY`, `NEXT_PUBLIC_API_URL`, `SOURCE_SECRET`, `NEXT_PUBLIC_SOROBAN_RPC_URL`, `NEXT_PUBLIC_DEFAULT_USDC_ADDRESS` +- **Core**: `DATABASE_URL`, `PORT`, `SOURCE_SECRET`, `SOROBAN_RPC_URL`, `USDC_CONTRACT_ID`, `*_WASM_HASH` (contract hashes) diff --git a/apps/backoffice-tokenization/.env.example b/apps/backoffice-tokenization/.env.example index aff2275..ebbf702 100644 --- a/apps/backoffice-tokenization/.env.example +++ b/apps/backoffice-tokenization/.env.example @@ -1,11 +1,11 @@ -# Trustless Work API Configuration -NEXT_PUBLIC_API_KEY= +# Trustless Work API +NEXT_PUBLIC_API_KEY="" -# Server-side only (for contract deployment) -SOURCE_SECRET= +# Core API (NestJS backend) +NEXT_PUBLIC_CORE_API_URL="http://localhost:4000" +NEXT_PUBLIC_BACKOFFICE_API_KEY="" -# Core API URL (NestJS backend) -NEXT_PUBLIC_CORE_API_URL=http://localhost:4000 - -# Core API Authentication -NEXT_PUBLIC_BACKOFFICE_API_KEY= +# Soroban / Stellar network +NEXT_PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +NEXT_PUBLIC_DEFAULT_USDC_ADDRESS="CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" \ No newline at end of file diff --git a/apps/backoffice-tokenization/messages/en.json b/apps/backoffice-tokenization/messages/en.json index 96db946..ac01d41 100644 --- a/apps/backoffice-tokenization/messages/en.json +++ b/apps/backoffice-tokenization/messages/en.json @@ -76,6 +76,8 @@ "filterAll": "All", "filterFundraising": "Fundraising", "filterActive": "Active", + "filterRepayment": "Repayment", + "filterClaimable": "Claimable", "filterClosed": "Closed", "searchPlaceholder": "Search campaigns...", "status": { @@ -157,6 +159,7 @@ "statusHeader": "Status", "actions": "Actions", "manageLoans": "Manage Loans", + "viewVault": "View Vault", "uploadFunds": "Upload Funds", "updateRoi": "Update ROI", "loadMore": "Load More", @@ -189,6 +192,19 @@ "disable": "Disable", "enabled": "Vault enabled", "disabled": "Vault disabled" + }, + "updateRoiDialog": { + "title": "Update ROI Percentage — {campaignName}", + "description": "Set a new ROI percentage for this campaign's vault. Value must be between 0 and 100.", + "fieldLabel": "ROI Percentage (%)", + "placeholder": "e.g. 12", + "updatingLabel": "Updating ROI...", + "submitLabel": "Update ROI Percentage", + "successToast": "ROI percentage updated successfully" + }, + "errors": { + "walletNotConnected": "Wallet not connected", + "unexpectedError": "Unexpected error" } }, "tokens": { @@ -238,6 +254,13 @@ "vaultContractAddress": "Vault Contract Address", "copied": "Copied!", "copy": "Copy", + "enableVaultSuccessToast": "Vault enabled successfully", + "errors": { + "failedBuildEnableTx": "Failed to build enable transaction.", + "transactionSubmissionFailed": "Transaction submission failed.", + "enableVaultRequestFailed": "Enable vault request did not succeed", + "unexpectedError": "Unexpected error" + }, "validation": { "priceRequired": "Price is required", "maxPrice": "Cannot exceed 100%", diff --git a/apps/backoffice-tokenization/messages/es.json b/apps/backoffice-tokenization/messages/es.json index 1f7e1d9..ed4ac0c 100644 --- a/apps/backoffice-tokenization/messages/es.json +++ b/apps/backoffice-tokenization/messages/es.json @@ -76,6 +76,8 @@ "filterAll": "Todas", "filterFundraising": "Recaudando", "filterActive": "Activa", + "filterRepayment": "En Pago", + "filterClaimable": "Reclamable", "filterClosed": "Cerrada", "searchPlaceholder": "Buscar campañas...", "status": { @@ -157,6 +159,7 @@ "statusHeader": "Estado", "actions": "Acciones", "manageLoans": "Gestionar Préstamos", + "viewVault": "Ver vault", "uploadFunds": "Subir Fondos", "updateRoi": "Actualizar ROI", "loadMore": "Cargar Más", @@ -189,6 +192,19 @@ "disable": "Deshabilitar", "enabled": "Vault habilitado", "disabled": "Vault deshabilitado" + }, + "updateRoiDialog": { + "title": "Actualizar porcentaje de ROI — {campaignName}", + "description": "Establezca un nuevo porcentaje de ROI para el vault de esta campaña. El valor debe estar entre 0 y 100.", + "fieldLabel": "Porcentaje de ROI (%)", + "placeholder": "ej. 12", + "updatingLabel": "Actualizando ROI...", + "submitLabel": "Actualizar porcentaje de ROI", + "successToast": "El porcentaje de ROI se actualizó correctamente" + }, + "errors": { + "walletNotConnected": "Wallet no conectada", + "unexpectedError": "Error inesperado" } }, "tokens": { @@ -238,6 +254,13 @@ "vaultContractAddress": "Dirección del Contrato Vault", "copied": "¡Copiado!", "copy": "Copiar", + "enableVaultSuccessToast": "Vault habilitado exitosamente", + "errors": { + "failedBuildEnableTx": "No se pudo construir la transacción para habilitar el vault.", + "transactionSubmissionFailed": "Falló el envío de la transacción.", + "enableVaultRequestFailed": "La solicitud para habilitar el vault no fue exitosa", + "unexpectedError": "Error inesperado" + }, "validation": { "priceRequired": "El precio es requerido", "maxPrice": "No puede exceder 100%", diff --git a/apps/backoffice-tokenization/next.config.ts b/apps/backoffice-tokenization/next.config.ts index e3fcf88..53534af 100644 --- a/apps/backoffice-tokenization/next.config.ts +++ b/apps/backoffice-tokenization/next.config.ts @@ -5,7 +5,12 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const nextConfig: NextConfig = { reactCompiler: true, - transpilePackages: ["@tokenization/shared", "@tokenization/ui", "@tokenization/tw-blocks-shared"], + transpilePackages: [ + "@tokenization/shared", + "@tokenization/ui", + "@tokenization/features", + "@tokenization/tw-blocks-shared", + ], turbopack: { root: "../../", }, diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx index cb4f553..4261aa3 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx @@ -1,18 +1,23 @@ -import { SectionTitle } from "@/components/shared/section-title"; + "use client"; + +import { SectionTitle } from "@tokenization/ui/section-title"; import { ManageLoansView } from "@/features/campaigns/components/loans/manage-loans-view"; +import { useTranslations } from "next-intl"; +import { use } from "react"; interface Props { params: Promise<{ id: string }>; } -export default async function CampaignLoansPage({ params }: Props) { - const { id } = await params; +export default function CampaignLoansPage({ params }: Props) { + const { id } = use(params); + const t = useTranslations("loans"); return (
diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx index fd5f50f..8778f70 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { SectionTitle } from "@/components/shared/section-title"; +import { SectionTitle } from "@tokenization/ui/section-title"; import { CreateCampaignStepper } from "@/features/campaigns/components/create/create-campaign-stepper"; import { useTranslations } from "next-intl"; diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx index 9bc8973..25507cc 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx @@ -1,33 +1,77 @@ "use client"; import { Link } from "@/i18n/navigation"; -import { SectionTitle } from "@/components/shared/section-title"; -import { CampaignsView } from "@/features/campaigns/components/campaigns-view"; +import { SharedCampaignsView } from "@tokenization/features/campaign"; import { Button } from "@tokenization/ui/button"; import { Plus } from "lucide-react"; import { useTranslations } from "next-intl"; +import type { SharedCampaign } from "@tokenization/shared/src/types/campaign"; +import { useCampaigns } from "@/features/campaigns/hooks/use-campaigns"; +import { CampaignList } from "@/features/campaigns/components/campaign-list"; +import type { Campaign } from "@/features/campaigns/types/campaign.types"; + +function backofficeToShared(c: Campaign): SharedCampaign { + return { + id: c.id, + title: c.name, + description: c.description ?? "", + status: c.status, + loansCompleted: 0, + investedAmount: 0, + currency: "USDC", + vaultId: c.vaultId ?? null, + escrowId: c.escrowId, + poolSize: c.poolSize, + }; +} export default function CampaignsPage() { const t = useTranslations("campaigns"); - return ( -
-
- -
+ const { data: rawCampaigns = [], isLoading, isError } = useCampaigns(); + const campaigns: SharedCampaign[] = rawCampaigns.map(backofficeToShared); - -
+ const toolbarLabels = { + searchPlaceholder: t("searchPlaceholder"), + filterAll: t("filterAll"), + filterFundraising: t("filterFundraising"), + filterActive: t("filterActive"), + filterRepayment: t("filterRepayment"), + filterClaimable: t("filterClaimable"), + filterClosed: t("filterClosed"), + }; + + if (isError) { + return ( +
+ {t("loadError")}
- -
+ ); + } + + return ( + + + + {t("newCampaign")} + + + } + toolbarLabels={toolbarLabels} + > + {(filteredCampaigns: SharedCampaign[]) => { + const ids = new Set(filteredCampaigns.map((c) => c.id)); + const rawFiltered = rawCampaigns.filter((r) => ids.has(r.id)); + return ; + }} + ); } diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx index da12dcf..f03fa50 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { SectionTitle } from "@/components/shared/section-title"; +import { SectionTitle } from "@tokenization/ui/section-title"; import { RoiView } from "@/features/campaigns/components/roi/roi-view"; import { useTranslations } from "next-intl"; diff --git a/apps/backoffice-tokenization/src/app/[locale]/layout.tsx b/apps/backoffice-tokenization/src/app/[locale]/layout.tsx index ee01c67..996354e 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/layout.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/layout.tsx @@ -9,7 +9,7 @@ import { Toaster } from "@tokenization/ui/sonner"; import { WalletProvider } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; import type { ReactNode } from "react"; import { Inter } from "next/font/google"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { NextIntlClientProvider } from "next-intl"; import { SharedTranslationProvider } from "@tokenization/tw-blocks-shared/src/i18n/TranslationProvider"; import sharedEn from "@tokenization/tw-blocks-shared/src/i18n/messages/en.json"; diff --git a/apps/backoffice-tokenization/src/app/[locale]/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/page.tsx index 791fa17..746b667 100644 --- a/apps/backoffice-tokenization/src/app/[locale]/page.tsx +++ b/apps/backoffice-tokenization/src/app/[locale]/page.tsx @@ -1,11 +1,9 @@ import { HomeView } from "@/features/home/HomeView"; -import { Header } from "@/components/shared/Header"; export default function Home() { return (
-
diff --git a/apps/backoffice-tokenization/src/components/layout/app-header.tsx b/apps/backoffice-tokenization/src/components/layout/app-header.tsx index b7ae838..171f29f 100644 --- a/apps/backoffice-tokenization/src/components/layout/app-header.tsx +++ b/apps/backoffice-tokenization/src/components/layout/app-header.tsx @@ -1,14 +1,21 @@ "use client"; import { SidebarTrigger } from "@tokenization/ui/sidebar"; -import { LanguageSwitcher } from "@/components/shared/language-switcher"; +import { LanguageSwitcher } from "@tokenization/ui/language-switcher"; +import { usePathname, useRouter } from "@/i18n/navigation"; export function AppHeader() { + const router = useRouter(); + const pathname = usePathname(); + return (
- + router.replace(pathname, { locale })} + />
); diff --git a/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx b/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx index 3a589ca..7b1a980 100644 --- a/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx +++ b/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx @@ -10,6 +10,7 @@ import { } from "@tokenization/ui/app-sidebar"; import { SidebarWalletButton } from "@tokenization/ui/sidebar-wallet-button"; import { useTranslations } from "next-intl"; +import { usePathname } from "@/i18n/navigation"; const logo: AppSidebarLogoConfig = { element: ( @@ -27,6 +28,7 @@ const logo: AppSidebarLogoConfig = { export function AppSidebar() { const t = useTranslations("nav"); + const pathname = usePathname(); const footerItems: AppSidebarFooterItem[] = [ { @@ -52,6 +54,7 @@ export function AppSidebar() { return ( { - return ( -
- - logo - -
- ); -}; diff --git a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx index db9dcdd..16fd6dc 100644 --- a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx +++ b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx @@ -11,8 +11,10 @@ import { Banknote, CheckCircle, Circle, ExternalLink, Landmark } from "lucide-re import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; import type { MultiReleaseMilestone } from "@trustless-work/escrow/types"; import type { Campaign } from "@/features/campaigns/types/campaign.types"; -import { getCampaignStatusConfig } from "@/features/campaigns/constants/campaign-status"; -import { formatCurrency } from "@/lib/utils"; +import { getCampaignStatusConfig } from "@tokenization/shared"; +import { formatCurrency } from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; +import { GetEscrowsFromIndexerResponse } from "@trustless-work/escrow/types"; +import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants"; interface CampaignCardProps { campaign: Campaign; @@ -20,22 +22,21 @@ interface CampaignCardProps { export function CampaignCard({ campaign }: CampaignCardProps) { const t = useTranslations("campaigns"); - const { id, name, description, status, escrowId } = campaign; + const { name, description, status, escrowId } = campaign; const statusCfg = getCampaignStatusConfig(t)[status]; const isDraft = status === "DRAFT"; - const escrowExplorerUrl = `https://viewer.trustlesswork.com/${escrowId}`; + const escrowExplorerUrl = `${ESCROW_EXPLORER_URL}${escrowId}`; const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); const { data: escrowData } = useQuery({ queryKey: ["escrow", escrowId], - // eslint-disable-next-line @typescript-eslint/no-explicit-any queryFn: async () => { const data = (await getEscrowByContractIds({ contractIds: [escrowId], validateOnChain: true, - })) as any; + })) as unknown as GetEscrowsFromIndexerResponse[]; return data?.[0] ?? null; }, enabled: !isDraft && !!escrowId, @@ -86,7 +87,7 @@ export function CampaignCard({ campaign }: CampaignCardProps) { footer={
- {t("poolSize")}: USDC {formatCurrency(escrowData?.balance ?? 0)} / USDC {formatCurrency(campaign.poolSize)} + {t("poolSize")}: {formatCurrency(escrowData?.balance ?? 0, "USDC")} / {formatCurrency(campaign.poolSize, "USDC")}
} diff --git a/apps/backoffice-tokenization/src/components/shared/section-title.tsx b/apps/backoffice-tokenization/src/components/shared/section-title.tsx deleted file mode 100644 index 2889b55..0000000 --- a/apps/backoffice-tokenization/src/components/shared/section-title.tsx +++ /dev/null @@ -1,15 +0,0 @@ -interface SectionTitleProps { - title: string; - description?: string; -} - -export function SectionTitle({ title, description }: SectionTitleProps) { - return ( -
-

{title}

- {description && ( -

{description}

- )} -
- ); -} diff --git a/apps/backoffice-tokenization/src/components/shared/stat-item.tsx b/apps/backoffice-tokenization/src/components/shared/stat-item.tsx deleted file mode 100644 index 6dd8bee..0000000 --- a/apps/backoffice-tokenization/src/components/shared/stat-item.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface StatItemProps { - label: string; - value: string | number; - description?: string; -} - -export function StatItem({ label, value, description }: StatItemProps) { - return ( -
- {label} - {value} - {description && ( - {description} - )} -
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx deleted file mode 100644 index 75afd97..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import type { CampaignStatus } from "@/features/campaigns/types/campaign.types"; - -interface CampaignFilterProps { - value: CampaignStatus | "all"; - onChange: (value: CampaignStatus | "all") => void; -} - -export function CampaignFilter({ value, onChange }: CampaignFilterProps) { - const t = useTranslations("campaigns"); - - const STATUS_OPTIONS: { value: CampaignStatus | "all"; label: string }[] = [ - { value: "all", label: t("filterAll") }, - { value: "FUNDRAISING", label: t("filterFundraising") }, - { value: "ACTIVE", label: t("filterActive") }, - { value: "CLOSED", label: t("filterClosed") }, - ]; - - return ( -
- {STATUS_OPTIONS.map((option) => ( - - ))} -
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx deleted file mode 100644 index 667ba1d..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { Search } from "lucide-react"; - -interface CampaignSearchProps { - value: string; - onChange: (value: string) => void; -} - -export function CampaignSearch({ value, onChange }: CampaignSearchProps) { - const t = useTranslations("campaigns"); - - return ( -
- - onChange(e.target.value)} - className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-ring" - /> -
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx deleted file mode 100644 index 78fd114..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { CampaignSearch } from "./campaign-search"; -import { CampaignFilter } from "./campaign-filter"; -import type { CampaignStatus } from "@/features/campaigns/types/campaign.types"; - -interface CampaignToolbarProps { - onSearchChange: (value: string) => void; - onFilterChange: (value: CampaignStatus | "all") => void; -} - -export function CampaignToolbar({ onSearchChange, onFilterChange }: CampaignToolbarProps) { - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - - const handleSearchChange = (value: string) => { - setSearch(value); - onSearchChange(value); - }; - - const handleFilterChange = (value: CampaignStatus | "all") => { - setFilter(value); - onFilterChange(value); - }; - - return ( -
- -
- -
-
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx deleted file mode 100644 index e9455e4..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import { useTranslations } from "next-intl"; -import { CampaignToolbar } from "./campaign-toolbar"; -import { CampaignList } from "./campaign-list"; -import { useCampaigns } from "@/features/campaigns/hooks/use-campaigns"; -import type { CampaignStatus } from "@/features/campaigns/types/campaign.types"; - -export function CampaignsView() { - const t = useTranslations("campaigns"); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - const { data: campaigns = [], isLoading, isError } = useCampaigns(); - - const filtered = useMemo(() => { - return campaigns.filter((c) => { - const matchesStatus = filter === "all" || c.status === filter; - const matchesSearch = - search.trim() === "" || - c.name.toLowerCase().includes(search.toLowerCase()) || - (c.description ?? "").toLowerCase().includes(search.toLowerCase()); - return matchesStatus && matchesSearch; - }); - }, [campaigns, search, filter]); - - if (isLoading) { - return ( -
- {t("loading")} -
- ); - } - - if (isError) { - return ( -
- {t("loadError")} -
- ); - } - - return ( -
- - -
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx deleted file mode 100644 index f7f05b6..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -interface ClaimRoiButtonProps { - campaignId: string; - disabled?: boolean; -} - -export function ClaimRoiButton({ campaignId, disabled }: ClaimRoiButtonProps) { - const handleClaim = () => { - // TODO: implement claim ROI transaction - console.log("Claiming ROI for campaign:", campaignId); - }; - - return ( - - ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx index 956a2f9..4001221 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx +++ b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx @@ -38,8 +38,8 @@ import { } 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 { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input"; -import { formatCurrency, fromStroops } from "@/lib/utils"; +import { numericInputKeyDown, parseNumericInput } from "@tokenization/shared/lib/utils"; +import { formatCurrency } from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; import { useTranslations } from "next-intl"; interface ManageLoansViewProps { diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx index ce43608..c4a50b9 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx +++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx @@ -16,6 +16,8 @@ import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/ import { useUpdateRoiPercentage } from "@/features/campaigns/hooks/useUpdateRoiPercentage"; import { getRoiPercentage } from "@/features/campaigns/services/campaigns.api"; import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { getContractExplorerUrl } from "@tokenization/shared/lib/constants"; interface UpdateRoiDialogProps { open: boolean; @@ -32,6 +34,7 @@ export function UpdateRoiDialog({ vaultId, onUpdated, }: UpdateRoiDialogProps) { + const t = useTranslations("roi"); const [percentage, setPercentage] = useState(""); const [isLoadingCurrent, setIsLoadingCurrent] = useState(false); const { walletAddress } = useWalletContext(); @@ -47,7 +50,7 @@ export function UpdateRoiDialog({ const { execute, isSubmitting, error } = useUpdateRoiPercentage({ onSuccess: () => { - toast.success("ROI percentage updated successfully"); + toast.success(t("updateRoiDialog.successToast")); onUpdated(); onOpenChange(false); setPercentage(""); @@ -65,22 +68,24 @@ export function UpdateRoiDialog({ - Update ROI Percentage — {campaignName} - - Set a new ROI percentage for this campaign's vault. Value must be between 0 and 100. - + + {t("updateRoiDialog.title", { + campaignName, + })} + + {t("updateRoiDialog.description")}
- + setPercentage(e.target.value)} disabled={isSubmitting || isLoadingCurrent} @@ -88,11 +93,6 @@ export function UpdateRoiDialog({ />
-

- Vault:{" "} - {vaultId} -

- {error ? (

{error}

) : null} @@ -105,10 +105,10 @@ export function UpdateRoiDialog({ {isSubmitting ? (
- Updating ROI... + {t("updateRoiDialog.updatingLabel")}
) : ( - "Update ROI Percentage" + t("updateRoiDialog.submitLabel") )}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx deleted file mode 100644 index 51074e2..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { - Dialog, - DialogContent, - DialogFooter, -} from "@tokenization/ui/dialog"; -import { Button } from "@tokenization/ui/button"; -import { Progress } from "@tokenization/ui/progress"; -import { Landmark, Zap } from "lucide-react"; -import { mapCampaignProgress } from "@/features/campaigns/utils/campaign.mapper"; -import type { AddFundsDialogProps } from "./types"; - -export function AddFundsDialog({ - campaign, - onClose, - onFundNow, -}: AddFundsDialogProps) { - const progress = campaign ? mapCampaignProgress(campaign) : 0; - - return ( - !open && onClose()}> - -
- {/* Icon + progress */} -
-
- -
- -
- - {/* Text */} -
-

- Fondear Campaña #{campaign?.id.slice(0, 3).toUpperCase()}{" "} - {campaign?.name} -

-

- El fondeo se puede realizar directamente desde el panel de la - plataforma o integrarse a través de nuestro SDK para flujos de - trabajo automatizados. Asegúrese de que su cuenta esté verificada - antes de continuar. -

-
- - {/* CTA */} - - - - - -
-
-
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx deleted file mode 100644 index 3ba8094..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@tokenization/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@tokenization/ui/form"; -import { Input } from "@tokenization/ui/input"; -import { Button } from "@tokenization/ui/button"; -import { ArrowRight, Info } from "lucide-react"; -import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input"; -import type { CreateRoiDialogProps, RoiFormValues } from "./types"; - -export function CreateRoiDialog({ - campaign, - form, - onClose, - onSubmit, -}: CreateRoiDialogProps) { - const t = useTranslations("roi"); - const tc = useTranslations("common"); - return ( - !open && onClose()}> - - - {t("createRoi.title")} - - {t("createRoi.description")} - - - -
- - ( - - {t("createRoi.priceLabel")} - -
- field.onChange(parseNumericInput(e.target.value, 100))} - /> - - % - -
-
- -
- )} - /> - - {/* Info box */} -
- -
- - {t("createRoi.howItWorks")} - -

- {t("createRoi.howItWorksDesc")} -

- -
-
- - - - - - - -
-
- ); -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx index 55c99f2..e9709cd 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx +++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx @@ -14,10 +14,11 @@ import { DropdownMenuSeparator, } from "@tokenization/ui/dropdown-menu"; import { cn } from "@tokenization/shared/lib/utils"; -import { ArrowUpCircle, Landmark, MoreHorizontal, Percent } from "lucide-react"; +import { getContractExplorerUrl } from "@tokenization/shared/lib/constants"; +import { ArrowUpCircle, Landmark, MoreHorizontal, Percent, Vault } from "lucide-react"; import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; -import { getCampaignStatusConfig } from "@/features/campaigns/constants/campaign-status"; -import { formatCurrency } from "@/lib/utils"; +import { getCampaignStatusConfig } from "@tokenization/shared"; +import { formatCurrency } from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; import { getVaultIsEnabled } from "@/features/campaigns/services/campaigns.api"; import { useVaultUsdcBalance } from "@/features/campaigns/hooks/useVaultUsdcBalance"; import { ToggleVaultButton } from "@/features/campaigns/components/roi/ToggleVaultButton"; @@ -96,6 +97,19 @@ export function RoiTableRow({ campaign, onAddFunds, onUpdateRoi }: RoiTableRowPr + {campaign.vaultId && ( + + + + {t("viewVault")} + + + )} diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts index ebd5e54..2eb64cf 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts @@ -6,12 +6,14 @@ import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/ import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; import { submitAndExtractAddress } from "@/features/campaigns/services/soroban.service"; import { updateRoiPorcentage } from "@/features/campaigns/services/campaigns.api"; +import { useTranslations } from "next-intl"; interface UseUpdateRoiPercentageParams { onSuccess?: () => void; } export function useUpdateRoiPercentage({ onSuccess }: UseUpdateRoiPercentageParams = {}) { + const t = useTranslations("roi"); const { walletAddress } = useWalletContext(); const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -19,7 +21,7 @@ export function useUpdateRoiPercentage({ onSuccess }: UseUpdateRoiPercentagePara const execute = async (vaultContractId: string, newRoiPorcentage: number) => { if (!walletAddress) { - setError("Wallet not connected"); + setError(t("errors.walletNotConnected")); return; } @@ -44,8 +46,7 @@ export function useUpdateRoiPercentage({ onSuccess }: UseUpdateRoiPercentagePara onSuccess?.(); } catch (e) { - const message = e instanceof Error ? e.message : "Unexpected error"; - setError(message); + setError(t("errors.unexpectedError")); } finally { setIsSubmitting(false); } diff --git a/apps/backoffice-tokenization/src/features/campaigns/services/soroban.service.ts b/apps/backoffice-tokenization/src/features/campaigns/services/soroban.service.ts index 6a6f3a3..21b3b52 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/services/soroban.service.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/services/soroban.service.ts @@ -1,10 +1,10 @@ import { rpc, - TransactionBuilder, - Networks, scValToNative, Address, } from "@stellar/stellar-sdk"; +import { SOROBAN_RPC_URL } from "@tokenization/shared/lib/constants"; +import { submitSignedTransactionAndWait } from "@tokenization/shared/lib/sorobanSubmitAndWait"; export interface DeployedContracts { participation_token: string; @@ -15,37 +15,18 @@ export interface DeployedContracts { export async function submitAndExtractDeployedContracts( signedXdr: string, ): Promise { - const server = new rpc.Server( - "https://soroban-testnet.stellar.org", - ); - const tx = TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET); - const send = await server.sendTransaction(tx); - if (send.status === "ERROR") { - throw new Error( - `Soroban error: ${JSON.stringify(send.errorResult)}`, - ); - } - - let result: rpc.Api.GetTransactionResponse | undefined; - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 2000)); - result = await server.getTransaction(send.hash); - if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) - break; - } + const result = await submitSignedTransactionAndWait(signedXdr, { + rpcUrl: SOROBAN_RPC_URL, + pollAttempts: 30, + pollDelayMs: 2000, + }); - if ( - !result || - result.status !== rpc.Api.GetTransactionStatus.SUCCESS - ) { - throw new Error( - `Transaction ${result?.status ?? "TIMEOUT"}`, - ); + if (result.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`Transaction ${result.status}`); } - const success = - result as rpc.Api.GetSuccessfulTransactionResponse; + const success = result as rpc.Api.GetSuccessfulTransactionResponse; if (!success.returnValue) { throw new Error("La transacción no retornó un valor"); @@ -58,37 +39,18 @@ export async function submitAndExtractDeployedContracts( export async function submitAndExtractAddress( signedXdr: string, ): Promise { - const server = new rpc.Server( - "https://soroban-testnet.stellar.org", - ); - const tx = TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET); - const send = await server.sendTransaction(tx); - if (send.status === "ERROR") { - throw new Error( - `Soroban error: ${JSON.stringify(send.errorResult)}`, - ); - } - - let result: rpc.Api.GetTransactionResponse | undefined; - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 2000)); - result = await server.getTransaction(send.hash); - if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) - break; - } + const result = await submitSignedTransactionAndWait(signedXdr, { + rpcUrl: SOROBAN_RPC_URL, + pollAttempts: 30, + pollDelayMs: 2000, + }); - if ( - !result || - result.status !== rpc.Api.GetTransactionStatus.SUCCESS - ) { - throw new Error( - `Transaction ${result?.status ?? "TIMEOUT"}`, - ); + if (result.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`Transaction ${result.status}`); } - const success = - result as rpc.Api.GetSuccessfulTransactionResponse; + const success = result as rpc.Api.GetSuccessfulTransactionResponse; try { return success.returnValue ? Address.fromScVal(success.returnValue).toString() diff --git a/apps/backoffice-tokenization/src/features/campaigns/services/transfer.service.ts b/apps/backoffice-tokenization/src/features/campaigns/services/transfer.service.ts index 0fa8f88..af5df96 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/services/transfer.service.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/services/transfer.service.ts @@ -7,9 +7,7 @@ import { Operation, BASE_FEE, } from "@stellar/stellar-sdk"; - -const SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; -const USDC_CONTRACT = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +import { SOROBAN_RPC_URL, USDC_ADDRESS } from "@tokenization/shared/lib/constants"; export async function buildUsdcTransferXdr(params: { from: string; @@ -22,7 +20,7 @@ export async function buildUsdcTransferXdr(params: { const stroops = BigInt(Math.round(params.amount * 10_000_000)); const transferOp = Operation.invokeContractFunction({ - contract: USDC_CONTRACT, + contract: USDC_ADDRESS, function: "transfer", args: [ new Address(params.from).toScVal(), diff --git a/apps/backoffice-tokenization/src/features/campaigns/services/vault-balance.service.ts b/apps/backoffice-tokenization/src/features/campaigns/services/vault-balance.service.ts index a013f3a..fbf8ebd 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/services/vault-balance.service.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/services/vault-balance.service.ts @@ -1,22 +1,12 @@ import { rpc, Address, scValToNative, xdr } from "@stellar/stellar-sdk"; +import { SOROBAN_RPC_URL, USDC_ADDRESS } from "@tokenization/shared/lib/constants"; -const SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; -const USDC_CONTRACT = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; - -/** - * Fetches the USDC balance held by a vault contract on Stellar Testnet. - * Reads the persistent ledger entry for DataKey::Balance(vaultId) directly — - * no transaction building or simulation required. - * - * @returns Balance in stroops (7 decimal places). Divide by 10_000_000 for display. - */ export async function getVaultUsdcBalance(vaultId: string): Promise { const server = new rpc.Server(SOROBAN_RPC_URL); - // Standard Soroban token DataKey::Balance(Address) encodes as Vec[Symbol("Balance"), Address] const ledgerKey = xdr.LedgerKey.contractData( new xdr.LedgerKeyContractData({ - contract: new Address(USDC_CONTRACT).toScAddress(), + contract: new Address(USDC_ADDRESS).toScAddress(), key: xdr.ScVal.scvVec([ xdr.ScVal.scvSymbol("Balance"), new Address(vaultId).toScVal(), @@ -32,11 +22,11 @@ export async function getVaultUsdcBalance(vaultId: string): Promise { const val = result.entries[0].val.contractData().val(); const native = scValToNative(val); - // SAC (Stellar Asset Contract) stores balance as { amount: bigint, authorized: bool, clawback: bool } - // Custom Soroban tokens store it as a plain bigint if (typeof native === "bigint") return native; + if (native !== null && typeof native === "object" && "amount" in native) { return native.amount as bigint; } + return BigInt(0); } diff --git a/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts b/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts deleted file mode 100644 index 813cd26..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type MilestoneStatus = "active" | "completed"; - -export interface Milestone { - id: string; - description: string; - walletAddress: string; - amount: number; - status: MilestoneStatus; -} - -export interface AddMilestoneFormValues { - description: string; - amount: number; -} diff --git a/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts b/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts deleted file mode 100644 index 425d743..0000000 --- a/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Campaign } from "@/features/campaigns/types/campaign.types"; - -export function mapCampaignProgress(_campaign: Campaign): number { - // Progress will be implemented once investment totals are available from the API - return 0; -} diff --git a/apps/backoffice-tokenization/src/features/home/BentoGrid.tsx b/apps/backoffice-tokenization/src/features/home/BentoGrid.tsx deleted file mode 100644 index 88cde40..0000000 --- a/apps/backoffice-tokenization/src/features/home/BentoGrid.tsx +++ /dev/null @@ -1,362 +0,0 @@ -"use client"; -import { cn } from "@/lib/utils"; -import React from "react"; -import { BentoGrid, BentoGridItem } from "@tokenization/ui/bento-grid"; -import { - IconBoxAlignRightFilled, - IconClipboardCopy, - IconFileBroken, - IconSignature, - IconTableColumn, -} from "@tabler/icons-react"; -import { motion } from "framer-motion"; -import { ClipboardCopyIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; - -export function BentoGridThirdDemo() { - const t = useTranslations("home"); - - const items = [ - { - title: t("bentoDeployTitle"), - description: ( - - {t("bentoDeployDesc")} - - ), - header: , - className: "md:col-span-1", - icon: , - }, - { - title: t("bentoApprovalsTitle"), - description: ( - - {t("bentoApprovalsDesc")} - - ), - header: , - className: "md:col-span-1", - icon: , - }, - { - title: t("bentoReleasesTitle"), - description: ( - - {t("bentoReleasesDesc")} - - ), - header: , - className: "md:col-span-1", - icon: , - }, - { - title: t("bentoDisputeTitle"), - description: ( - - {t("bentoDisputeDesc")} - - ), - header: , - className: "md:col-span-2", - icon: , - }, - { - title: t("bentoMonitorTitle"), - description: ( - - {t("bentoMonitorDesc")} - - ), - header: , - className: "md:col-span-1", - icon: , - }, - ]; - - return ( - - {items.map((item, i) => ( - p:text-lg]", item.className)} - icon={item.icon} - /> - ))} - - ); -} - -const SkeletonOne = () => { - const variants = { - initial: { - x: 0, - }, - animate: { - x: 10, - rotate: 5, - transition: { - duration: 0.2, - }, - }, - }; - const variantsSecond = { - initial: { - x: 0, - }, - animate: { - x: -10, - rotate: -5, - transition: { - duration: 0.2, - }, - }, - }; - - return ( - - -
-
- - -
-
- - -
-
- - - ); -}; -const SkeletonTwo = () => { - const variants = { - initial: { - width: 0, - }, - animate: { - width: "100%", - transition: { - duration: 0.2, - }, - }, - hover: { - width: ["0%", "100%"], - transition: { - duration: 2, - }, - }, - }; - const arr = new Array(6).fill(0); - return ( - - {/* Use deterministic widths to avoid SSR/client hydration mismatches */} - {arr.map((_, i) => { - const deterministicWidths = [52, 44, 66, 59, 85, 71]; // percentages - const widthPercent = - deterministicWidths[i % deterministicWidths.length]; - return ( - - ); - })} - - ); -}; -const SkeletonThree = () => { - const variants = { - initial: { - backgroundPosition: "0 50%", - }, - animate: { - backgroundPosition: ["0, 50%", "100% 50%", "0 50%"], - }, - }; - return ( - - - - ); -}; -const SkeletonFour = ({ t }: { t: (key: string) => string }) => { - const first = { - initial: { - x: 20, - rotate: -5, - }, - hover: { - x: 0, - rotate: 0, - }, - }; - const second = { - initial: { - x: -20, - rotate: 5, - }, - hover: { - x: 0, - rotate: 0, - }, - }; - return ( - - - avatar -

- {t("bentoDeployCards")} -

-

- {t("bentoSetup")} -

-
- - avatar -

- {t("bentoApproveCards")} -

-

- {t("bentoApproval")} -

-
- - avatar -

- {t("bentoReleaseCards")} -

-

- {t("bentoRelease")} -

-
-
- ); -}; -const SkeletonFive = ({ t }: { t: (key: string) => string }) => { - const variants = { - initial: { - x: 0, - }, - animate: { - x: 10, - rotate: 5, - transition: { - duration: 0.2, - }, - }, - }; - const variantsSecond = { - initial: { - x: 0, - }, - animate: { - x: -10, - rotate: -5, - transition: { - duration: 0.2, - }, - }, - }; - - return ( - - - avatar -

- {t("bentoManageCards")} -

-
- -

{t("bentoOperations")}

-
- - - ); -}; diff --git a/apps/backoffice-tokenization/src/features/manage-escrows/ManageEscrowsView.tsx b/apps/backoffice-tokenization/src/features/manage-escrows/ManageEscrowsView.tsx deleted file mode 100644 index 390b4b7..0000000 --- a/apps/backoffice-tokenization/src/features/manage-escrows/ManageEscrowsView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { EscrowsBySignerCards } from "@tokenization/tw-blocks-shared/src/escrows/escrows-by-signer/cards/EscrowsCards"; -import { InitializeEscrowDialog } from "@tokenization/tw-blocks-shared/src/escrows/multi-release/initialize-escrow/dialog/InitializeEscrow"; -import { TokenizeEscrowDialog } from "@/features/tokens/deploy/dialog/TokenizeEscrow"; -import { CreateVaultDialog } from "../vaults/deploy/dialog/CreateVault"; -import { EnableVaultDialog } from "../vaults/deploy/dialog/EnableVault"; - -export const ManageEscrowsView = () => { - return ( -
-
-
- - - - - - -
- -
-
- - -
-
- ); -}; diff --git a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx index 955610b..170ecdb 100644 --- a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx +++ b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx @@ -19,7 +19,7 @@ import { } from "@tokenization/ui/dialog"; import { Loader2 } from "lucide-react"; import { useCreateVault } from "./useCreateVault"; -import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input"; +import { numericInputKeyDown, parseNumericInput } from "@tokenization/shared/lib/utils"; import { useWatch } from "react-hook-form"; import { VaultDeploySuccessDialog } from "./VaultDeploySuccessDialog"; import { useTranslations } from "next-intl"; diff --git a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useCreateVault.ts b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useCreateVault.ts index a551fe9..c339ed3 100644 --- a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useCreateVault.ts +++ b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useCreateVault.ts @@ -5,6 +5,7 @@ import { type DeployVaultResponse, } from "@/features/vaults/services/vault.service"; import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; +import { USDC_ADDRESS } from "@tokenization/shared/lib/constants"; export type CreateVaultFormValues = { price: number; @@ -15,8 +16,6 @@ type UseCreateVaultParams = { onSuccess?: (response: DeployVaultResponse) => void; }; -const USDC_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; - export function useCreateVault(params?: UseCreateVaultParams) { const { walletAddress } = useWalletContext(); diff --git a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts index 88c64be..abe751d 100644 --- a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts +++ b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts @@ -6,10 +6,11 @@ import { } from "@/features/vaults/services/vault.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 { SendTransactionService } from "@tokenization/shared/lib/sendTransactionService"; +import { toastSuccessWithTx } from "@tokenization/ui/toast-with-tx"; import { updateCampaignStatusByVaultId } from "@/features/campaigns/services/campaigns.api"; import { useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; export type EnableVaultFormValues = { vaultContractAddress: string; @@ -20,6 +21,7 @@ type UseEnableVaultParams = { }; export function useEnableVault(params?: UseEnableVaultParams) { + const t = useTranslations("vaults"); const { walletAddress } = useWalletContext(); const queryClient = useQueryClient(); @@ -47,9 +49,7 @@ export function useEnableVault(params?: UseEnableVaultParams) { }); if (!enableResponse?.success || !enableResponse?.xdr) { - throw new Error( - enableResponse?.message ?? "Failed to build enable transaction." - ); + throw new Error(t("errors.failedBuildEnableTx")); } const signedTxXdr = await signTransaction({ @@ -63,12 +63,10 @@ export function useEnableVault(params?: UseEnableVaultParams) { }); if (submitResponse.status !== "SUCCESS") { - throw new Error( - submitResponse.message ?? "Transaction submission failed." - ); + throw new Error(t("errors.transactionSubmissionFailed")); } - toastSuccessWithTx("Vault enabled successfully", submitResponse.hash); + toastSuccessWithTx(t("enableVaultSuccessToast"), submitResponse.hash); try { await updateCampaignStatusByVaultId( @@ -85,11 +83,28 @@ export function useEnableVault(params?: UseEnableVaultParams) { if (enableResponse?.success) { params?.onSuccess?.(enableResponse); } else { - setError("Enable vault request did not succeed"); + setError(t("errors.enableVaultRequestFailed")); } } catch (e) { - const message = e instanceof Error ? e.message : "Unexpected error"; - setError(message); + if (!(e instanceof Error)) { + setError(t("errors.unexpectedError")); + return; + } + + const failedBuildEnableTxMessage = t("errors.failedBuildEnableTx"); + const transactionSubmissionFailedMessage = t("errors.transactionSubmissionFailed"); + const enableVaultRequestFailedMessage = t("errors.enableVaultRequestFailed"); + + if ( + e.message === failedBuildEnableTxMessage || + e.message === transactionSubmissionFailedMessage || + e.message === enableVaultRequestFailedMessage + ) { + setError(e.message); + return; + } + + setError(t("errors.unexpectedError")); } finally { setIsSubmitting(false); } diff --git a/apps/backoffice-tokenization/src/lib/numeric-input.ts b/apps/backoffice-tokenization/src/lib/numeric-input.ts deleted file mode 100644 index 138d929..0000000 --- a/apps/backoffice-tokenization/src/lib/numeric-input.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { KeyboardEvent } from "react"; - -const ALLOWED_KEYS = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"]; - -export function numericInputKeyDown(e: KeyboardEvent) { - if ( - !/[0-9.,]/.test(e.key) && - !ALLOWED_KEYS.includes(e.key) && - !e.ctrlKey && - !e.metaKey - ) { - e.preventDefault(); - } -} - -export function parseNumericInput(value: string, max?: number): number { - const num = Number(value.replace(/[^0-9.,]/g, "").replace(",", ".")); - const safe = isNaN(num) ? 0 : num; - return max !== undefined ? Math.min(safe, max) : safe; -} diff --git a/apps/backoffice-tokenization/src/lib/sendTransactionService.ts b/apps/backoffice-tokenization/src/lib/sendTransactionService.ts deleted file mode 100644 index bf8ddde..0000000 --- a/apps/backoffice-tokenization/src/lib/sendTransactionService.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@tokenization/shared/lib/sendTransactionService"; diff --git a/apps/backoffice-tokenization/src/lib/tokenDeploymentService.ts b/apps/backoffice-tokenization/src/lib/tokenDeploymentService.ts deleted file mode 100644 index 5a7a22c..0000000 --- a/apps/backoffice-tokenization/src/lib/tokenDeploymentService.ts +++ /dev/null @@ -1,90 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { SorobanClient } from "./sorobanClient"; - -const tokenSalePath = path.join(process.cwd(), "services/wasm/token_sale.wasm"); -const tokenFactoryPath = path.join( - process.cwd(), - "services/wasm/soroban_token_contract.wasm", -); - -export type TokenDeploymentParams = { - escrowContractId: string; - tokenName: string; - tokenSymbol: string; -}; - -export type TokenDeploymentResult = { - tokenFactoryAddress: string; - tokenSaleAddress: string; -}; - -export const deployTokenContracts = async ( - client: SorobanClient, - { escrowContractId, tokenName, tokenSymbol }: TokenDeploymentParams, -): Promise => { - const tokenFactoryWasm = fs.readFileSync(tokenFactoryPath); - const tokenSaleWasm = fs.readFileSync(tokenSalePath); - - // Upload WASM files sequentially to avoid overwhelming the network - // and to get better error messages if one fails - console.log("Starting TokenFactory WASM upload..."); - const tokenFactoryWasmHash = await client.uploadContractWasm( - tokenFactoryWasm, - "TokenFactory WASM upload", - ); - console.log("TokenFactory WASM uploaded successfully"); - - console.log("Starting TokenSale WASM upload..."); - const tokenSaleWasmHash = await client.uploadContractWasm( - tokenSaleWasm, - "TokenSale WASM upload", - ); - console.log("TokenSale WASM uploaded successfully"); - - // SOLUTION: Deploy TokenSale first with placeholder, then deploy TokenFactory, - // then update TokenSale with the real TokenFactory address using set_token - - // Step 1: Deploy TokenSale first with placeholder token address (deployer address) - // This allows us to get the TokenSale address for TokenFactory deployment - console.log("Deploying TokenSale..."); - const tokenSaleAddress = await client.createContract( - tokenSaleWasmHash, - [ - client.nativeAddress(escrowContractId), // escrow_contract - client.nativeAddress(client.publicKey), // sale_token (placeholder - will be updated) - client.nativeAddress(client.publicKey), // admin (deployer can update token address) - ], - "TokenSale contract creation", - ); - console.log(`TokenSale deployed at: ${tokenSaleAddress}`); - - // Step 2: Deploy TokenFactory with TokenSale address as mint_authority - console.log("Deploying TokenFactory..."); - console.log(`Deployer public address: ${client.publicKey}`); - console.log(`Mint authority: ${tokenSaleAddress}`); - const tokenFactoryAddress = await client.createContract( - tokenFactoryWasmHash, - [ - client.nativeString(tokenName), // name (user-provided) - client.nativeString(tokenSymbol), // symbol (user-provided) - client.nativeString(escrowContractId), // escrow_id - client.nativeU32(7), // decimal - client.nativeAddress(tokenSaleAddress), // mint_authority (Token Sale contract) - ], - "TokenFactory contract creation", - ); - console.log(`TokenFactory deployed at: ${tokenFactoryAddress}`); - - // Step 3: Update TokenSale with the real TokenFactory address - console.log("Updating TokenSale with correct token address..."); - await client.callContract( - tokenSaleAddress, - "set_token", - [client.nativeAddress(tokenFactoryAddress)], - "Update TokenSale token address", - ); - console.log("✅ TokenSale updated successfully with correct token address."); - - return { tokenFactoryAddress, tokenSaleAddress }; -}; diff --git a/apps/backoffice-tokenization/src/lib/utils.ts b/apps/backoffice-tokenization/src/lib/utils.ts deleted file mode 100644 index 5c16b04..0000000 --- a/apps/backoffice-tokenization/src/lib/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { cn } from "@tokenization/shared/lib/utils"; - -const SOROBAN_DECIMAL_SCALE = 1e7; - -export function fromStroops(stroops: number | string): number { - return Number(stroops) / SOROBAN_DECIMAL_SCALE; -} - -export function formatCurrency(amount: number, currency?: string): string { - const formatted = new Intl.NumberFormat("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount); - return currency ? `${currency} ${formatted}` : formatted; -} diff --git a/apps/backoffice-tokenization/src/lib/vaultDeploymentService.ts b/apps/backoffice-tokenization/src/lib/vaultDeploymentService.ts deleted file mode 100644 index 81c8a4a..0000000 --- a/apps/backoffice-tokenization/src/lib/vaultDeploymentService.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import fs from "fs"; -import path from "path"; -import { SorobanClient } from "./sorobanClient"; - -const vaultContractPath = path.join( - process.cwd(), - "services/wasm/vault_contract.wasm", -); - -export type VaultDeploymentParams = { - admin: string; - enabled: boolean; - price: number | string; - token: string; - usdc: string; -}; - -export type VaultDeploymentResult = { - vaultContractAddress: string; -}; - -export const deployVaultContract = async ( - client: SorobanClient, - { admin, enabled, price, token, usdc }: VaultDeploymentParams, -): Promise => { - const vaultWasm = fs.readFileSync(vaultContractPath); - - const vaultWasmHash = await client.uploadContractWasm( - vaultWasm, - "Vault WASM upload", - ); - - const vaultContractAddress = await client.createContract( - vaultWasmHash, - [ - client.nativeAddress(admin), - StellarSDK.nativeToScVal(enabled, { type: "bool" }), - StellarSDK.nativeToScVal(price, { type: "i128" }), - client.nativeAddress(token), - client.nativeAddress(usdc), - ], - "Vault contract creation", - ); - - return { vaultContractAddress }; -}; diff --git a/apps/core/.env.example b/apps/core/.env.example index a898cd2..3dae55d 100644 --- a/apps/core/.env.example +++ b/apps/core/.env.example @@ -1,14 +1,22 @@ +# Database DATABASE_URL="postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres" -PORT=4000 + +# Server +PORT="4000" +ALLOWED_ORIGINS="" + +# API keys (must match keys used by frontends; Core validates x-api-key against these) +BACKOFFICE_API_KEY="" +INVESTORS_API_KEY="" # Stellar / Soroban -SOURCE_SECRET="" SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +HORIZON_URL="https://horizon-testnet.stellar.org" -# WASM Hashes (subidos una sola vez a la red) -TOKEN_FACTORY_WASM_HASH="" +# WASM hashes (uploaded once to the network) PARTICIPATION_TOKEN_WASM_HASH="" +TOKEN_SALE_WASM_HASH="" VAULT_WASM_HASH="" -# Deployer Contract +# Deployer contract DEPLOYER_CONTRACT_ID="" diff --git a/apps/core/src/deploy/deploy.service.ts b/apps/core/src/deploy/deploy.service.ts index ced1e77..0701ab4 100644 --- a/apps/core/src/deploy/deploy.service.ts +++ b/apps/core/src/deploy/deploy.service.ts @@ -9,7 +9,15 @@ import { DeployVaultDto } from './dto/deploy-vault.dto'; import { SetAdminDto } from './dto/set-admin.dto'; const TOKEN_DECIMAL = 7; -const USDC_CONTRACT_ID = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const DEFAULT_USDC_CONTRACT_ID = + 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; + +function getUsdcContractId(): string { + return ( + process.env.USDC_CONTRACT_ID?.trim() || + DEFAULT_USDC_CONTRACT_ID + ); +} @Injectable() export class DeployService { @@ -83,7 +91,7 @@ export class DeployService { token_sale_admin: dto.callerPublicKey, token_sale_salt: randomBytes(32), token_symbol: dto.tokenSymbol, - usdc: USDC_CONTRACT_ID, + usdc: getUsdcContractId(), vault_admin: dto.callerPublicKey, vault_enabled: false, vault_salt: randomBytes(32), diff --git a/apps/core/src/vault/vault.service.ts b/apps/core/src/vault/vault.service.ts index ea25c96..a84b810 100644 --- a/apps/core/src/vault/vault.service.ts +++ b/apps/core/src/vault/vault.service.ts @@ -35,7 +35,8 @@ export class VaultService { return this.soroban.buildContractCallTransaction( dto.contractId, 'update_roi_porcentage', - { new_roi_porcentage: dto.newRoiPorcentage }, + // The contract expects `new_roi_percentage` (with "c"), even though the method name is spelled "porcentage". + { new_roi_percentage: dto.newRoiPorcentage }, dto.callerPublicKey, 'vault', ); diff --git a/apps/investor-tokenization/.env.example b/apps/investor-tokenization/.env.example index 278ad15..6eb2ab0 100644 --- a/apps/investor-tokenization/.env.example +++ b/apps/investor-tokenization/.env.example @@ -1,14 +1,11 @@ -# Soroban/Stellar Network Configuration -NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org -NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -NEXT_PUBLIC_DEFAULT_USDC_ADDRESS= +# Trustless Work API +NEXT_PUBLIC_API_KEY="" -# Trustless Work API Configuration -NEXT_PUBLIC_API_KEY= - -# Server-side only (for contract deployment) -SOURCE_SECRET= - -# Core API Authentication -NEXT_PUBLIC_INVESTORS_API_KEY= +# Core API (NestJS backend) NEXT_PUBLIC_CORE_API_URL=http://localhost:4000 +NEXT_PUBLIC_INVESTORS_API_KEY="" + +# Soroban / Stellar network +NEXT_PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +NEXT_PUBLIC_DEFAULT_USDC_ADDRESS="CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" \ No newline at end of file diff --git a/apps/investor-tokenization/messages/en.json b/apps/investor-tokenization/messages/en.json index 237c789..3af4bd9 100644 --- a/apps/investor-tokenization/messages/en.json +++ b/apps/investor-tokenization/messages/en.json @@ -34,6 +34,7 @@ "filterClaimable": "Claimable", "filterClosed": "Closed", "empty": "No campaigns found.", + "loading": "Loading campaigns...", "loadingCampaigns": "Loading campaigns...", "status": { "DRAFT": "Draft", @@ -79,6 +80,7 @@ "yourInvestment": "Your investment", "estimatedReturn": "Estimated return ({rate}% x {duration}mo)", "totalAtMaturity": "Total at maturity", + "disclaimerLabel": "Disclaimer", "disclaimer": "Please review your investment amount carefully. Once confirmed, these amounts are not editable and the transaction is final.", "processing": "Processing...", "addingToken": "Adding share to wallet...", @@ -92,7 +94,9 @@ "invalidAmount": "Enter a valid amount greater than 0.", "unexpectedError": "Unexpected error while processing your investment.", "insufficientBalance": "Insufficient USDC balance. Please ensure your wallet has enough USDC to complete this transaction. You can get testnet USDC from a Stellar testnet faucet.", - "failedBuild": "Failed to build buy transaction." + "failedBuild": "Failed to build buy transaction.", + "failedAddToken": "Failed to add share to Freighter.", + "sorobanErrorPrefix": "Soroban error" } }, "claimRoi": { diff --git a/apps/investor-tokenization/messages/es.json b/apps/investor-tokenization/messages/es.json index 97088c8..e7420d9 100644 --- a/apps/investor-tokenization/messages/es.json +++ b/apps/investor-tokenization/messages/es.json @@ -34,6 +34,7 @@ "filterClaimable": "Reclamable", "filterClosed": "Cerrada", "empty": "No se encontraron campañas.", + "loading": "Cargando campañas...", "loadingCampaigns": "Cargando campañas...", "status": { "DRAFT": "Borrador", @@ -79,6 +80,7 @@ "yourInvestment": "Tu inversión", "estimatedReturn": "Retorno estimado ({rate}% x {duration}mo)", "totalAtMaturity": "Total al vencimiento", + "disclaimerLabel": "Aviso", "disclaimer": "Por favor revisa el monto de tu inversión cuidadosamente. Una vez confirmado, estos montos no son editables y la transacción es final.", "processing": "Procesando...", "addingToken": "Agregando acción a la billetera...", @@ -92,7 +94,9 @@ "invalidAmount": "Ingresa un monto válido mayor a 0.", "unexpectedError": "Error inesperado al procesar tu inversión.", "insufficientBalance": "Balance insuficiente de USDC. Asegúrate de que tu billetera tenga suficiente USDC para completar esta transacción. Puedes obtener USDC de testnet en un faucet de Stellar testnet.", - "failedBuild": "No se pudo construir la transacción de compra." + "failedBuild": "No se pudo construir la transacción de compra.", + "failedAddToken": "No se pudo agregar la acción a Freighter.", + "sorobanErrorPrefix": "Error de Soroban" } }, "claimRoi": { diff --git a/apps/investor-tokenization/next.config.ts b/apps/investor-tokenization/next.config.ts index c08b3bf..d6365e0 100644 --- a/apps/investor-tokenization/next.config.ts +++ b/apps/investor-tokenization/next.config.ts @@ -8,6 +8,7 @@ const nextConfig: NextConfig = { transpilePackages: [ "@tokenization/shared", "@tokenization/ui", + "@tokenization/features", "@tokenization/tw-blocks-shared", ], async rewrites() { diff --git a/apps/investor-tokenization/src/app/[locale]/campaigns/page.tsx b/apps/investor-tokenization/src/app/[locale]/campaigns/page.tsx index 4ccbd56..90b4be2 100644 --- a/apps/investor-tokenization/src/app/[locale]/campaigns/page.tsx +++ b/apps/investor-tokenization/src/app/[locale]/campaigns/page.tsx @@ -1,28 +1,66 @@ "use client"; -import { useState } from "react"; -import { SectionTitle } from "@/components/shared/section-title"; -import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar"; -import { ProjectList } from "@/features/transparency/ProjectList"; -import type { CampaignStatus } from "@/features/roi/types/campaign.types"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; +import { SharedCampaignsView } from "@tokenization/features/campaign"; +import type { SharedCampaign } from "@tokenization/shared/src/types/campaign"; +import { CampaignsGrid } from "@/features/transparency/CampaignsGrid"; +import { fetchCampaigns } from "@/features/transparency/services/campaign.service"; +import type { CampaignFromApi, CampaignStatus } from "@/features/roi/types/campaign.types"; + +const HIDDEN_STATUSES: CampaignStatus[] = ["DRAFT", "PAUSED"]; + +function apiToShared(c: CampaignFromApi): SharedCampaign { + return { + id: c.id, + title: c.name, + description: c.description ?? "", + status: c.status, + loansCompleted: 0, + investedAmount: 0, + currency: "USDC", + vaultId: c.vaultId ?? null, + escrowId: c.escrowId, + poolSize: c.poolSize, + }; +} export default function CampaignsPage() { const t = useTranslations("campaigns"); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); + + const { data: rawCampaigns = [], isLoading } = useQuery({ + queryKey: ["campaigns"], + queryFn: fetchCampaigns, + }); + + const visibleRaw = rawCampaigns.filter((c) => !HIDDEN_STATUSES.includes(c.status)); + const campaigns: SharedCampaign[] = visibleRaw.map(apiToShared); + + const toolbarLabels = { + searchPlaceholder: t("searchPlaceholder"), + filterAll: t("filterAll"), + filterFundraising: t("filterFundraising"), + filterActive: t("filterActive"), + filterRepayment: t("filterRepayment"), + filterClaimable: t("filterClaimable"), + filterClosed: t("filterClosed"), + }; return ( -
- - - -
+ + {(filteredCampaigns: SharedCampaign[]) => { + const ids = new Set(filteredCampaigns.map((c) => c.id)); + const rawFiltered = visibleRaw.filter((r) => ids.has(r.id)); + return ; + }} + ); } diff --git a/apps/investor-tokenization/src/app/[locale]/claim-roi/page.tsx b/apps/investor-tokenization/src/app/[locale]/claim-roi/page.tsx deleted file mode 100644 index 5e60b6c..0000000 --- a/apps/investor-tokenization/src/app/[locale]/claim-roi/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ClaimROIView } from "@/features/claim-roi/ClaimROIView"; - -export default function ClaimROI() { - return ; -} diff --git a/apps/investor-tokenization/src/app/[locale]/investments/page.tsx b/apps/investor-tokenization/src/app/[locale]/investments/page.tsx deleted file mode 100644 index fada1d1..0000000 --- a/apps/investor-tokenization/src/app/[locale]/investments/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { InvestmentsView } from "@/features/investments/InvestmentsView"; - -export default function InvestmentsPage() { - return ; -} - - diff --git a/apps/investor-tokenization/src/app/[locale]/layout.tsx b/apps/investor-tokenization/src/app/[locale]/layout.tsx index b98f8ef..a0ef53c 100644 --- a/apps/investor-tokenization/src/app/[locale]/layout.tsx +++ b/apps/investor-tokenization/src/app/[locale]/layout.tsx @@ -9,7 +9,7 @@ import { Toaster } from "@tokenization/ui/sonner"; import { WalletProvider } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; import type { ReactNode } from "react"; import { Inter } from "next/font/google"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { NextIntlClientProvider } from "next-intl"; import { SharedTranslationProvider } from "@tokenization/tw-blocks-shared/src/i18n/TranslationProvider"; import sharedEn from "@tokenization/tw-blocks-shared/src/i18n/messages/en.json"; diff --git a/apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx b/apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx index 570f8df..6f50f90 100644 --- a/apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx +++ b/apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx @@ -1,19 +1,13 @@ "use client"; -import { useState, useMemo, useCallback } from "react"; -import { SectionTitle } from "@/components/shared/section-title"; -import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar"; +import { useMemo, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { SharedCampaignsView } from "@tokenization/features/campaign"; 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"; -import { useTranslations } from "next-intl"; +import { useClaimROI } from "@/features/claim-roi/hooks/useClaimROI"; function toCampaign(inv: InvestmentFromApi): Campaign { return { @@ -45,100 +39,59 @@ function aggregateByCampaign(investments: InvestmentFromApi[]): Campaign[] { export default function MyInvestmentsPage() { const t = useTranslations("investments"); + const tCampaigns = useTranslations("campaigns"); const tClaimRoi = useTranslations("claimRoi"); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - const { data: investments, isLoading } = useUserInvestments(); - const { walletAddress } = useWalletContext(); + const { data: investments, isLoading } = useUserInvestments(); 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 { claimROI } = useClaimROI({ + noVault: tClaimRoi("noVaultAvailable"), + connectWallet: tClaimRoi("connectToClaim"), + buildFailed: tClaimRoi("buildFailed"), + claimFailed: tClaimRoi("claimFailed"), + success: tClaimRoi("claimSuccess"), + unexpectedError: tClaimRoi("unexpectedError"), + }); const handleClaimRoi = useCallback( async (campaignId: string) => { const campaign = campaigns.find((c) => c.id === campaignId); - - if (!campaign?.vaultId) { - toast.error(tClaimRoi("noVaultAvailable")); - return; - } - - if (!walletAddress) { - toast.error(tClaimRoi("connectToClaim")); - 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 ?? tClaimRoi("buildFailed"), - ); - } - - 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 ?? tClaimRoi("claimFailed"), - ); - } - - toastSuccessWithTx(tClaimRoi("claimSuccess"), submitResponse.hash); - } catch (e) { - const msg = e instanceof Error ? e.message : tClaimRoi("unexpectedError"); - toast.error(msg); - } + if (!campaign?.vaultId) return; + await claimROI({ vaultContractId: campaign.vaultId }); }, - [campaigns, walletAddress, tClaimRoi], + [campaigns, claimROI], ); + const toolbarLabels = { + searchPlaceholder: tCampaigns("searchPlaceholder"), + filterAll: tCampaigns("filterAll"), + filterFundraising: tCampaigns("filterFundraising"), + filterActive: tCampaigns("filterActive"), + filterRepayment: tCampaigns("filterRepayment"), + filterClaimable: tCampaigns("filterClaimable"), + filterClosed: tCampaigns("filterClosed"), + }; + return ( -
- - - {isLoading ? ( -

- {t("loadingYourInvestments")} -

- ) : ( - + + {(filteredCampaigns) => ( + )} -
+ ); } diff --git a/apps/investor-tokenization/src/components/layout/app-header.tsx b/apps/investor-tokenization/src/components/layout/app-header.tsx index b7ae838..171f29f 100644 --- a/apps/investor-tokenization/src/components/layout/app-header.tsx +++ b/apps/investor-tokenization/src/components/layout/app-header.tsx @@ -1,14 +1,21 @@ "use client"; import { SidebarTrigger } from "@tokenization/ui/sidebar"; -import { LanguageSwitcher } from "@/components/shared/language-switcher"; +import { LanguageSwitcher } from "@tokenization/ui/language-switcher"; +import { usePathname, useRouter } from "@/i18n/navigation"; export function AppHeader() { + const router = useRouter(); + const pathname = usePathname(); + return (
- + router.replace(pathname, { locale })} + />
); diff --git a/apps/investor-tokenization/src/components/layout/app-sidebar.tsx b/apps/investor-tokenization/src/components/layout/app-sidebar.tsx index 321b112..9b56c5e 100644 --- a/apps/investor-tokenization/src/components/layout/app-sidebar.tsx +++ b/apps/investor-tokenization/src/components/layout/app-sidebar.tsx @@ -10,7 +10,7 @@ import { } from "@tokenization/ui/app-sidebar"; import { SidebarWalletButton } from "@tokenization/ui/sidebar-wallet-button"; import { useTranslations } from "next-intl"; -import { Link } from "@/i18n/navigation"; +import { Link, usePathname } from "@/i18n/navigation"; const logo: AppSidebarLogoConfig = { element: ( @@ -37,6 +37,7 @@ const footerItems: AppSidebarFooterItem[] = [ export function AppSidebar() { const t = useTranslations("nav"); + const pathname = usePathname(); const navItems: AppSidebarNavItem[] = [ { @@ -53,6 +54,7 @@ export function AppSidebar() { return ( - {locales.map(({ code, label }) => ( - - ))} -
- ); -} diff --git a/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx b/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx deleted file mode 100644 index 9c5eb02..0000000 --- a/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx +++ /dev/null @@ -1,520 +0,0 @@ -"use client"; -import React, { - ReactNode, - useCallback, - useEffect, - useRef, - useState, - createContext, - useContext, -} from "react"; -import { - IconArrowNarrowLeft, - IconArrowNarrowRight, - IconX, -} from "@tabler/icons-react"; -import { cn } from "@/lib/utils"; -import { AnimatePresence, motion } from "framer-motion"; -import { useOutsideClick } from "@/hooks/use-outside-click"; -import { useQuery } from "@tanstack/react-query"; -import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; -import { GetEscrowsFromIndexerResponse as Escrow } from "@trustless-work/escrow/types"; -import { RainbowButton } from "@tokenization/ui/rainbow-button"; -import { ClaimROIService } from "@/features/claim-roi/services/claim.service"; -import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; -import { toast } from "sonner"; -import { InvestDialog } from "@/features/tokens/components/InvestDialog"; -import { SelectedEscrowProvider } from "@/features/tokens/context/SelectedEscrowContext"; -import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; -import { SendTransactionService } from "@/lib/sendTransactionService"; -import { toastSuccessWithTx } from "@/lib/toastWithTx"; - -interface CarouselProps { - items: ReactNode[]; - initialScroll?: number; - escrowIds?: string[]; - hideInvest?: boolean; - showClaimAction?: boolean; -} - -type Card = { - escrowId: string; - tokenSale?: string; - tokenFactory?: string; - vaultContractId?: string; - campaignId?: string; - src: string; - content: React.ReactNode; -}; - -// Single escrow item type from library types -type EscrowItem = Escrow; - -const NOOP = () => undefined; - -export const CarouselContext = createContext<{ - onCardClose: (index: number) => void; - currentIndex: number; - escrowsById: Record | null; - preferBulk: boolean; - isLoadingEscrows: boolean; - hideInvest: boolean; - showClaimAction: boolean; -}>({ - onCardClose: NOOP, - currentIndex: 0, - escrowsById: null, - preferBulk: false, - isLoadingEscrows: false, - hideInvest: false, - showClaimAction: false, -}); - -export const Carousel = ({ - items, - initialScroll = 0, - escrowIds, - hideInvest = false, - showClaimAction = false, -}: CarouselProps) => { - const carouselRef = React.useRef(null); - const [canScrollLeft, setCanScrollLeft] = React.useState(false); - const [canScrollRight, setCanScrollRight] = React.useState(true); - const [currentIndex, setCurrentIndex] = useState(0); - const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); - - // Fetch all escrows at once if a list of ids is provided - const { data: escrowsList, isLoading: isEscrowsLoading } = useQuery< - EscrowItem[] - >({ - queryKey: ["escrows-by-ids", escrowIds], - queryFn: async () => { - const list = (await getEscrowByContractIds({ - contractIds: escrowIds ?? [], - validateOnChain: true, - })) as unknown as EscrowItem[]; - return Array.isArray(list) ? list : []; - }, - enabled: Array.isArray(escrowIds) && escrowIds.length > 0, - }); - - const escrowsById = React.useMemo(() => { - if (!Array.isArray(escrowIds) || !Array.isArray(escrowsList)) return null; - const map: Record = {}; - escrowsList.forEach((item, idx) => { - const key = - (item as unknown as { contractId?: string })?.contractId ?? - escrowIds[idx]; - if (key) { - map[key] = item; - } - }); - return map; - }, [escrowIds, escrowsList]); - - const checkScrollability = useCallback(() => { - if (carouselRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft < scrollWidth - clientWidth); - } - }, []); - - useEffect(() => { - if (carouselRef.current) { - carouselRef.current.scrollLeft = initialScroll; - checkScrollability(); - } - }, [initialScroll, checkScrollability]); - - const scrollLeft = () => { - if (carouselRef.current) { - carouselRef.current.scrollBy({ left: -300, behavior: "smooth" }); - } - }; - - const scrollRight = () => { - if (carouselRef.current) { - carouselRef.current.scrollBy({ left: 300, behavior: "smooth" }); - } - }; - - const handleCardClose = (index: number) => { - if (carouselRef.current) { - const cardWidth = isMobile() ? 230 : 384; // (md:w-96) - const gap = isMobile() ? 4 : 8; - const scrollPosition = (cardWidth + gap) * (index + 1); - carouselRef.current.scrollTo({ - left: scrollPosition, - behavior: "smooth", - }); - setCurrentIndex(index); - } - }; - - const isMobile = () => { - return typeof window !== "undefined" && window.innerWidth < 768; - }; - - return ( - 0, - isLoadingEscrows: isEscrowsLoading, - hideInvest, - showClaimAction, - }} - > -
-
- - -
- -
-
- -
- {(() => { - const isBulkLoading = - Array.isArray(escrowIds) && isEscrowsLoading; - const itemsToRender: ReactNode[] = isBulkLoading - ? Array.from({ length: Math.max(items.length, 3) }, () => ( -
-
-
-
-
-
-
-
- )) - : items; - return itemsToRender; - })().map((item, index) => ( - - {item} - - ))} -
-
-
- - ); -}; - -export const Card = ({ - card, - index, - layout = false, -}: { - card: Card; - index: number; - layout?: boolean; -}) => { - const [open, setOpen] = useState(false); - const [isClaiming, setIsClaiming] = useState(false); - const containerRef = useRef(null); - const { - onCardClose, - escrowsById, - preferBulk, - isLoadingEscrows, - hideInvest, - showClaimAction, - } = useContext(CarouselContext); - const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); - const { walletAddress } = useWalletContext(); - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const handleClose = useCallback(() => { - setOpen(false); - onCardClose(index); - }, [index, onCardClose]); - - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - handleClose(); - } - } - - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = "auto"; - } - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [open, handleClose]); - - const escrowFromContext = escrowsById?.[card.escrowId]; - - const { data: fetchedEscrow } = useQuery({ - queryKey: ["escrow", card.escrowId], - queryFn: async () => { - const list = (await getEscrowByContractIds({ - contractIds: [card.escrowId], - validateOnChain: true, - })) as unknown as EscrowItem[]; - return Array.isArray(list) ? (list[0] as EscrowItem) : undefined; - }, - // Only allow per-card fetch if we are NOT using bulk mode - enabled: - !preferBulk && - !escrowFromContext && - Boolean(card.escrowId) && - !isLoadingEscrows, - }); - - const escrow = escrowFromContext ?? fetchedEscrow; - - useOutsideClick(containerRef as React.RefObject, handleClose); - - const handleClaim = async () => { - try { - if (!card.vaultContractId) { - toast.error("Vault contract ID not available for this card"); - return; - } - - if (!walletAddress) { - toast.error("Connect your wallet to claim"); - return; - } - - setIsClaiming(true); - - const svc = new ClaimROIService(); - const claimResponse = await svc.claimROI({ - vaultContractId: card.vaultContractId, - 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"; - toast.error(msg); - } finally { - setIsClaiming(false); - } - }; - - return ( - <> - - {open && ( -
- - -
-
- {!hideInvest && - (card.tokenSale ? ( - - - - ) : ( - Invest - ))} - - {showClaimAction ? ( - - ) : null} -
- - -
- - {escrow?.title} - - - {escrow?.description} - -
- {React.isValidElement(card.content) - ? React.cloneElement( - card.content as React.ReactElement, - { - details: escrow, - tokenFactory: card.tokenFactory, - } - ) - : card.content} -
-
-
- )} -
- -
-
- - {escrow?.title} - - - {escrow?.description.slice(0, 100)} - -
- - - - ); -}; - -type BlurImageProps = React.ImgHTMLAttributes & { - src: string; - alt?: string; -}; - -export const BlurImage = ({ - height, - width, - src, - className, - alt, - ...rest -}: BlurImageProps) => { - return ( - {alt - ); -}; diff --git a/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx b/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx deleted file mode 100644 index 8847d12..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { VaultList } from "./VaultList"; -import { useTranslations } from "next-intl"; - -export const ClaimROIView = () => { - const t = useTranslations("claimRoi"); - - return ( -
-
-

{t("title")}

-

- {t("description")} -

-
- - -
- ); -}; diff --git a/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx b/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx deleted file mode 100644 index e01103a..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// src/features/claim-roi/VaultCard.tsx -"use client"; - -import { Card } from "@/components/ui/apple-cards-carousel"; -import { useVaultInfo } from "./hooks/useVaultInfo"; -import { useTranslations } from "next-intl"; - -export type VaultCardProps = { - vault: { - escrowId: string; - tokenSale: string; - vaultContractId: string; - src: string; - content?: React.ReactNode; - }; - index: number; -}; - -// ! IMPORTANT: Not working -export default function VaultCard({ vault, index }: VaultCardProps) { - const t = useTranslations("claimRoi"); - const { vaultContractId, src } = vault; - const { data, isLoading } = useVaultInfo(vaultContractId); - - return ( - - - - {isLoading ? ( -

- {t("loadingVault")} -

- ) : ( -
-
-

{t("vaultBalance")}

-

- {data?.vaultUsdcBalance ?? 0} USDC -

-
- -
-

{t("yourTokenBalance")}

-

- {data?.userTokenBalance ?? 0} Tokens -

-
- -
-

{t("yourClaimableRoi")}

-

- {data?.claimableRoi ?? 0} USDC -

-
-
- )} -
- ), - }} - index={index} - /> - ); -} diff --git a/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx b/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx deleted file mode 100644 index f0de9de..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Carousel, Card } from "@/components/ui/apple-cards-carousel"; -import { DummyContent } from "@/features/transparency/Carousel"; -import VaultCard from "./VaultCard"; - - -const data = [ - { - escrowId: "CCZHTYVLK6R2QMIFBTEN65ZVCSFBD3L5TXYCZJT5WTXE63ABYXBBCSEB", - tokenSale: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT", - vaultContractId: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT", - src: "/escrows/car.png", - content: , - }, - { - escrowId: "CD775STBXITO4GNNIS7GO3KV6SYMFNBQXW536SWXOPUOO36Z64N3XBFI", - tokenSale: "CD64ILP3SXCCY67QIVPCOVUX5Z5Q42CMKU7LK4RNAPCWD5QGBS6G7LPA", - vaultContractId: "CBFXUH4PIGABJSRIN3GOAPH2FYL4P443UQQM3RIUV2W2EHZDNX2I4ZMA", - src: "/escrows/car.png", - content: , - }, -]; - -export const VaultList = () => { - const cards = data.map((vault, index) => ( - - )); - - return ( -
- d.escrowId)} - hideInvest - showClaimAction - /> -
- ); -}; diff --git a/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts b/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts deleted file mode 100644 index 5e1328f..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts +++ /dev/null @@ -1,29 +0,0 @@ -// src/features/claim-roi/config/vaultConfig.ts - -export const VAULT_CONFIG: Record = { - "CAS56N75A3KYYON72ELM65FVEOV36VGLTQAOFQJIRYN7HM4L2H6TFOWD": { - price: 50, // +50% ROI - tokenAddress: "CBW4W4GEGD5MNXCUHGOAJ64IXLFHDMDDD65ITVM3HVIYSK22PGSHIJ5N", - usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - enabled: true, - }, - - "CCAPAJY44JOBHJAWVZCGAWAMK4XCST4EIZENZWI6DQHPD4RGYBKM6H7D": { - price: 20, - tokenAddress: "CBFTQZ3NATN6Y7PKYWGCF7OH6JOFTWUMYAJQDCBPSKGPWWQ7N23RTSNK", - usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - enabled: true, - }, - - "CC7RJ4EACM5HMYKIMYJ5OPYDOYING7AAHE5ACHZK3F7QSXIMIWRU5GFC": { - price: 35, - tokenAddress: "CBW4W4GEGD5MNXCUHGOAJ64IXLFHDMDDD65ITVM3HVIYSK22PGSHIJ5N", - usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - enabled: false, // example disabled vault - }, -}; diff --git a/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts b/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts new file mode 100644 index 0000000..d3380b5 --- /dev/null +++ b/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useCallback, useMemo } 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 { ClaimROIService } from "@/features/claim-roi/services/claim.service"; +import { SendTransactionService } from "@tokenization/shared/lib/sendTransactionService"; +import { toastSuccessWithTx } from "@tokenization/ui/toast-with-tx"; +import { toast } from "sonner"; + +export type ClaimROIMessages = { + noVault: string; + connectWallet: string; + buildFailed: string; + claimFailed: string; + success: string; + unexpectedError: string; +}; + +const DEFAULT_MESSAGES: ClaimROIMessages = { + noVault: "No vault available for this campaign", + connectWallet: "Connect your wallet to claim", + buildFailed: "Failed to build claim transaction", + claimFailed: "Claim failed", + success: "ROI claimed successfully", + unexpectedError: "Unexpected error", +}; + +export function useClaimROI(messages?: Partial) { + const { walletAddress } = useWalletContext(); + const [isClaiming, setIsClaiming] = useState(false); + + const msgs = useMemo( + () => ({ ...DEFAULT_MESSAGES, ...messages }), + [messages], + ); + + const claimROI = useCallback( + async (params: { vaultContractId: string }) => { + const { vaultContractId } = params; + + if (!vaultContractId) { + toast.error(msgs.noVault); + return; + } + + if (!walletAddress) { + toast.error(msgs.connectWallet); + return; + } + + setIsClaiming(true); + try { + const svc = new ClaimROIService(); + const claimResponse = await svc.claimROI({ + vaultContractId, + beneficiaryAddress: walletAddress, + }); + + if (!claimResponse?.success || !claimResponse?.xdr) { + throw new Error(claimResponse?.message ?? msgs.buildFailed); + } + + 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 ?? msgs.claimFailed, + ); + } + + toastSuccessWithTx(msgs.success, submitResponse.hash); + } catch (e) { + const msg = e instanceof Error ? e.message : msgs.unexpectedError; + toast.error(msg); + } finally { + setIsClaiming(false); + } + }, + [walletAddress, msgs], + ); + + return { claimROI, isClaiming }; +} diff --git a/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts b/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts deleted file mode 100644 index 2effb58..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts +++ /dev/null @@ -1,17 +0,0 @@ -// src/features/claim-roi/hooks/useVaultInfo.ts -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; -import { getVaultInfo } from "../services/vaultInfo.service"; - -export const useVaultInfo = (vaultContractId: string) => { - // 🔥 Get the real wallet address from WalletContext - const { walletAddress } = useWalletContext(); - - return useQuery({ - queryKey: ["vault-info", vaultContractId, walletAddress], - queryFn: () => getVaultInfo(vaultContractId, walletAddress!), - enabled: !!walletAddress && !!vaultContractId, - }); -}; diff --git a/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts b/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts deleted file mode 100644 index 0a5dc88..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts +++ /dev/null @@ -1,59 +0,0 @@ -// src/features/claim-roi/services/getTokenBalance.ts -import { - Contract, - rpc, - scValToNative, - Address, - TransactionBuilder, -} from "@stellar/stellar-sdk"; - -const server = new rpc.Server( - process.env.NEXT_PUBLIC_SOROBAN_RPC_URL!, - { allowHttp: true } -); - -export async function getTokenBalance( - tokenContractId: string, - holderAddress: string, - simulationSource: string // NEW: must be a real account -): Promise { - try { - // ⭐ Use the REAL account (wallet) to simulate - const source = await server.getAccount(simulationSource); - - const contract = new Contract(tokenContractId); - - // Build a tx calling balance() - const tx = new TransactionBuilder(source, { - networkPassphrase: process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE!, - fee: "100", - }) - .addOperation( - contract.call( - "balance", - new Address(holderAddress).toScVal() - ) - ) - .setTimeout(30) - .build(); - - // Simulate - const sim: any = await server.simulateTransaction(tx); - - // Handle ALL possible SDK versions - const retval = - sim?.result?.retval || - sim?.results?.[0]?.retval || - undefined; - - if (!retval) return 0; - - const raw = scValToNative(retval); - - return Number(raw) / 1_0000000; - - } catch (error) { - console.error("Token balance RPC error:", error); - return 0; - } -} diff --git a/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts b/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts deleted file mode 100644 index b01b3ad..0000000 --- a/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -// src/features/claim-roi/services/vaultInfo.service.ts -import { getTokenBalance } from "./getTokenBalance"; -import { VAULT_CONFIG } from "../config/vaultConfig"; - -export async function getVaultInfo(vaultId: string, userAddress: string) { - const config = VAULT_CONFIG[vaultId]; - if (!config) throw new Error(`Vault not found: ${vaultId}`); - - const { price, tokenAddress, usdcAddress, enabled } = config; - - // 1) Vault USDC balance - const vaultUsdcBalance = await getTokenBalance( - usdcAddress, // USDC token contract - vaultId, // vault contract address (C…) - userAddress // real user address for simulation - ); - - // 2) User participation token balance - const userTokenBalance = await getTokenBalance( - tokenAddress, // participation token contract - userAddress, // user account - userAddress // real user address - ); - - // 3) ROI formula - const claimableRoi = - userTokenBalance > 0 ? (userTokenBalance * (100 + price)) / 100 : 0; - - return { - enabled, - price, - tokenAddress, - usdcAddress, - vaultUsdcBalance, - userTokenBalance, - claimableRoi, - }; -} diff --git a/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx b/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx deleted file mode 100644 index 4afc42b..0000000 --- a/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useUserInvestments } from "./hooks/useUserInvestments.hook"; -import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; -import { InvestmentCard } from "./components/InvestmentCard"; -import { Card } from "@tokenization/ui/card"; -import { Button } from "@tokenization/ui/button"; -import { Wallet, TrendingUp, DollarSign } from "lucide-react"; -import { useWallet } from "@tokenization/tw-blocks-shared/src/wallet-kit/useWallet"; -import { useTranslations } from "next-intl"; - -export const InvestmentsView = () => { - const t = useTranslations("investments"); - const tCommon = useTranslations("common"); - const { walletAddress } = useWalletContext(); - const { handleConnect } = useWallet(); - - const { - data: investments, - isLoading, - isError, - error, - } = useUserInvestments(); - - if (!walletAddress) { - return ( -
-
-

{t("title")}

-

- {t("connectDescription")} -

- -
-
- ); - } - - if (isLoading) { - return ( -
-
-

{t("title")}

-

{t("loadingYourInvestments")}

-
-
- ); - } - - if (isError) { - return ( -
-
-

{t("title")}

-

- {t("loadError", { message: error instanceof Error ? error.message : tCommon("unknownError") })} -

-
-
- ); - } - - const investmentList = investments ?? []; - - const totalInvested = React.useMemo(() => { - return investmentList.reduce((sum, inv) => sum + Number(inv.usdcAmount), 0); - }, [investmentList]); - - const uniqueCampaigns = React.useMemo(() => { - return new Set(investmentList.map((inv) => inv.campaignId)).size; - }, [investmentList]); - - return ( -
-
-
-

{t("title")}

-

- {t("description")} -

-
- -
- -
-
- -
-
-

{t("totalInvestments")}

-

{investmentList.length}

-
-
-
- - -
-
- -
-
-

{t("campaignsLabel")}

-

{uniqueCampaigns}

-
-
-
- - -
-
- -
-
-

{t("totalInvested")}

-

- {totalInvested.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{" "} - USDC -

-
-
-
-
- - {investmentList.length === 0 ? ( - - -

{t("noInvestmentsTitle")}

-

- {t("noInvestmentsDesc")} -

-
- ) : ( -
- {investmentList.map((investment) => ( - - ))} -
- )} -
-
- ); -}; diff --git a/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx b/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx deleted file mode 100644 index ff2c475..0000000 --- a/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@tokenization/ui/card"; -import { Badge } from "@tokenization/ui/badge"; -import { - formatAddress, -} from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; -import { Calendar, DollarSign, ExternalLink, Coins } from "lucide-react"; -import { Link } from "@/i18n/navigation"; -import type { InvestmentFromApi } from "../services/investment.service"; -import { useTranslations } from "next-intl"; -import { getCampaignStatusConfig } from "@/features/roi/constants/campaign-status"; -import type { CampaignStatus } from "@/features/roi/types/campaign.types"; - -type InvestmentCardProps = { - investment: InvestmentFromApi; -}; - -const STATUS_VARIANT: Record = { - ACTIVE: "default", - FUNDRAISING: "default", - REPAYMENT: "secondary", - CLAIMABLE: "secondary", - CLOSED: "outline", - PAUSED: "destructive", - DRAFT: "outline", -}; - -export const InvestmentCard = ({ investment }: InvestmentCardProps) => { - const t = useTranslations("investments"); - const tCampaigns = useTranslations("campaigns"); - const { campaign } = investment; - const usdcAmount = Number(investment.usdcAmount); - const tokenAmount = Number(investment.tokenAmount); - const createdAt = new Date(investment.createdAt); - const statusConfig = getCampaignStatusConfig(tCampaigns); - const statusLabel = statusConfig[campaign.status as CampaignStatus]?.label ?? campaign.status; - - return ( - - -
- - {campaign.name} - - - {statusLabel} - -
- {campaign.description && ( -

- {campaign.description} -

- )} -
- - -
-
-
- - - {t("invested")} - -
-

- {usdcAmount.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-

USDC

-
- -
-
- - - {t("tokens")} - -
-

- {tokenAmount.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-

{t("received")}

-
-
- - {campaign.expectedReturn > 0 && ( -
-
- {t("expectedReturn")} - - {Number(campaign.expectedReturn)}% - -
- {campaign.loanDuration > 0 && ( -
- {t("duration")} - - {t("durationMonths", { count: campaign.loanDuration })} - -
- )} -
- )} - -
-
- - - {t("investedOn")} - - - {createdAt.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - })} - -
- -
- {t("txHash")} - - {formatAddress(investment.txHash)} - - -
- - {campaign.escrowId && ( -
- {t("escrow")} - - {formatAddress(campaign.escrowId)} - - -
- )} -
-
-
- ); -}; diff --git a/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts b/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts deleted file mode 100644 index 9ef4f39..0000000 --- a/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; -import { InvestmentService } from "../services/investment.service"; - -export type ProjectTokenBalanceInfo = { - escrowId: string; - tokenFactory: string; - balance: string; - tokenName?: string; - tokenSymbol?: string; - tokenDecimals?: number; -}; - -// Project data from ProjectList - includes tokenFactory addresses -const PROJECT_DATA = [ - { - escrowId: "CCZHTYVLK6R2QMIFBTEN65ZVCSFBD3L5TXYCZJT5WTXE63ABYXBBCSEB", - tokenSale: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT", - tokenFactory: "CDJTII2GR2FY6Q4NDJGZI7NW2SHQ7GR5Y2H7B7Q253PTZZAZZ25TFYYU", - }, - { - escrowId: "CD775STBXITO4GNNIS7GO3KV6SYMFNBQXW536SWXOPUOO36Z64N3XBFI", - tokenFactory: "CCEFVY5PCQEO7KSNS52DUXOFXU7PXMQ4GPT25S5ZDYZQPWA74XXY5Y5N", - tokenSale: "CD64ILP3SXCCY67QIVPCOVUX5Z5Q42CMKU7LK4RNAPCWD5QGBS6G7LPA", - }, -]; - -/** - * Hook to fetch token balances and metadata for all projects - * Returns a map of escrowId -> token balance info - */ -export const useProjectTokenBalances = () => { - const { walletAddress } = useWalletContext(); - const investmentService = new InvestmentService(); - - return useQuery>({ - queryKey: ["project-token-balances", walletAddress], - queryFn: async () => { - if (!walletAddress) { - return {}; - } - - // Check token balances and metadata for all projects in parallel - const balanceChecks = await Promise.allSettled( - PROJECT_DATA.map(async (project) => { - try { - const [balanceResponse, metadataResponse] = await Promise.all([ - investmentService.getTokenBalance({ - tokenFactoryAddress: project.tokenFactory, - address: walletAddress, - }), - investmentService.getTokenMetadata({ - tokenFactoryAddress: project.tokenFactory, - }).catch(() => ({ - success: false, - name: undefined, - symbol: undefined, - decimals: 7, - })), - ]); - - const balance = balanceResponse.success - ? balanceResponse.balance || "0" - : "0"; - - return { - escrowId: project.escrowId, - tokenFactory: project.tokenFactory, - balance, - tokenName: metadataResponse.success ? metadataResponse.name : undefined, - tokenSymbol: metadataResponse.success ? metadataResponse.symbol : undefined, - tokenDecimals: metadataResponse.success ? metadataResponse.decimals : 7, - }; - } catch (error) { - console.warn( - `Failed to check balance for project ${project.escrowId}:`, - error, - ); - return { - escrowId: project.escrowId, - tokenFactory: project.tokenFactory, - balance: "0", - tokenName: undefined, - tokenSymbol: undefined, - tokenDecimals: 7, - }; - } - }), - ); - - // Build a map of escrowId -> balance info - const balancesMap: Record = {}; - balanceChecks.forEach((result) => { - if (result.status === "fulfilled") { - balancesMap[result.value.escrowId] = result.value; - } - }); - - return balancesMap; - }, - enabled: Boolean(walletAddress), - staleTime: 1000 * 60 * 2, // 2 minutes - }); -}; - diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx deleted file mode 100644 index 171d4cc..0000000 --- a/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import type { CampaignStatus } from "../types/campaign.types"; - -interface CampaignFilterProps { - value: CampaignStatus | "all"; - onChange: (value: CampaignStatus | "all") => void; -} - -export function CampaignFilter({ value, onChange }: CampaignFilterProps) { - const t = useTranslations("campaigns"); - - const STATUS_OPTIONS: { value: CampaignStatus | "all"; labelKey: string }[] = [ - { value: "all", labelKey: "filterAll" }, - { value: "FUNDRAISING", labelKey: "filterFundraising" }, - { value: "ACTIVE", labelKey: "filterActive" }, - { value: "REPAYMENT", labelKey: "filterRepayment" }, - { value: "CLAIMABLE", labelKey: "filterClaimable" }, - { value: "CLOSED", labelKey: "filterClosed" }, - ]; - - return ( -
- {STATUS_OPTIONS.map((option) => ( - - ))} -
- ); -} diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx index ab1dd35..a1c6c4a 100644 --- a/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx +++ b/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import type { Campaign } from "../types/campaign.types"; -import { CampaignCard } from "./campaign-card"; +import { CampaignCard } from "@/components/shared/campaign-card"; type CampaignListProps = { campaigns: Campaign[]; diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx deleted file mode 100644 index 58a144a..0000000 --- a/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { Search } from "lucide-react"; -import { useTranslations } from "next-intl"; - -interface CampaignSearchProps { - value: string; - onChange: (value: string) => void; -} - -export function CampaignSearch({ value, onChange }: CampaignSearchProps) { - const t = useTranslations("campaigns"); - - return ( -
- - onChange(e.target.value)} - className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-ring" - /> -
- ); -} diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx deleted file mode 100644 index 9cda0a2..0000000 --- a/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { CampaignSearch } from "./campaign-search"; -import { CampaignFilter } from "./campaign-filter"; -import type { CampaignStatus } from "../types/campaign.types"; - -interface CampaignToolbarProps { - onSearchChange: (value: string) => void; - onFilterChange: (value: CampaignStatus | "all") => void; -} - -export function CampaignToolbar({ onSearchChange, onFilterChange }: CampaignToolbarProps) { - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - - const handleSearchChange = (value: string) => { - setSearch(value); - onSearchChange(value); - }; - - const handleFilterChange = (value: CampaignStatus | "all") => { - setFilter(value); - onFilterChange(value); - }; - - return ( -
- -
- -
-
- ); -} diff --git a/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx b/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx deleted file mode 100644 index 802f4bf..0000000 --- a/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@tokenization/ui/avatar"; -import { cn } from "@/lib/utils"; - -type InvestorProfileCardProps = { - name?: string; - avatarUrl?: string | null; - label?: string; - className?: string; -}; - -const defaultName = "Investor"; -const defaultLabel = "Investor"; - -export function InvestorProfileCard({ - name = defaultName, - avatarUrl, - label = defaultLabel, - className, -}: InvestorProfileCardProps) { - const initials = name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); - - return ( -
- - {avatarUrl ? ( - - ) : null} - - {initials} - - -
-

{name}

-

{label}

-
-
- ); -} diff --git a/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts b/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts deleted file mode 100644 index 72890a4..0000000 --- a/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CampaignStatus } from "../types/campaign.types"; - -type StatusConfig = { label: string; className: string }; - -type TranslationFn = (key: string) => string; - -export function getCampaignStatusConfig( - t: TranslationFn, -): Record { - return { - DRAFT: { label: t("status.DRAFT"), className: "bg-secondary text-text-muted border-border" }, - FUNDRAISING: { label: t("status.FUNDRAISING"), className: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - ACTIVE: { label: t("status.ACTIVE"), className: "bg-success-bg text-success border-success/30" }, - REPAYMENT: { label: t("status.REPAYMENT"), className: "bg-blue-50 text-blue-700 border-blue-200" }, - CLAIMABLE: { label: t("status.CLAIMABLE"), className: "bg-purple-50 text-purple-700 border-purple-200" }, - CLOSED: { label: t("status.CLOSED"), className: "bg-secondary text-text-muted border-border" }, - PAUSED: { label: t("status.PAUSED"), className: "bg-orange-50 text-orange-700 border-orange-200" }, - }; -} - -/** - * @deprecated Use getCampaignStatusConfig(t) instead for i18n support. - */ -export const CAMPAIGN_STATUS_CONFIG: Record = { - DRAFT: { label: "Draft", className: "bg-secondary text-text-muted border-border" }, - FUNDRAISING: { label: "Fundraising", className: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - ACTIVE: { label: "Active", className: "bg-success-bg text-success border-success/30" }, - REPAYMENT: { label: "Repayment", className: "bg-blue-50 text-blue-700 border-blue-200" }, - CLAIMABLE: { label: "Claimable", className: "bg-purple-50 text-purple-700 border-purple-200" }, - CLOSED: { label: "Closed", className: "bg-secondary text-text-muted border-border" }, - PAUSED: { label: "Paused", className: "bg-orange-50 text-orange-700 border-orange-200" }, -}; diff --git a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx index b9a9e28..5974283 100644 --- a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx +++ b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx @@ -24,14 +24,6 @@ import { type BuyTokenPayload, } from "@/features/tokens/services/token.service"; import { addToken } from "@stellar/freighter-api"; - -const SOROBAN_RPC_URL = - process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? - "https://soroban-testnet.stellar.org"; -const NETWORK_PASSPHRASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? - "Test SDF Network ; September 2015"; - import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider"; import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; import { useSelectedEscrow } from "@/features/tokens/context/SelectedEscrowContext"; @@ -39,9 +31,10 @@ import { createInvestment } from "@/features/investments/services/investment.ser import { MultiReleaseMilestone } from "@trustless-work/escrow"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { fromStroops } from "@/utils/adjustedAmounts"; +import { fromStroops } from "@tokenization/shared/lib/utils"; import { Networks, rpc, TransactionBuilder } from "@stellar/stellar-sdk"; import { useTranslations } from "next-intl"; +import { USDC_ADDRESS, SOROBAN_RPC_URL, NETWORK_PASSPHRASE } from "@tokenization/shared/lib/constants"; type InvestFormValues = { amount: number; @@ -54,8 +47,6 @@ interface InvestDialogProps { loanDuration?: number; } -const DEFAULT_USDC_ADDRESS = process.env.NEXT_PUBLIC_DEFAULT_USDC_ADDRESS ?? ""; - export function InvestDialog({ tokenSaleContractId, triggerLabel, @@ -112,7 +103,7 @@ export function InvestDialog({ if (addTokenResult.error) { throw new Error( - addTokenResult.error ?? "Failed to add token to Freighter.", + addTokenResult.error ?? t("errors.failedAddToken"), ); } } @@ -121,7 +112,7 @@ export function InvestDialog({ setSubmitStep("buy"); const payload: BuyTokenPayload = { tokenSaleContractId, - usdcAddress: DEFAULT_USDC_ADDRESS, + usdcAddress: USDC_ADDRESS, payerAddress: walletAddress, beneficiaryAddress: walletAddress, amount: values.amount, @@ -145,7 +136,7 @@ export function InvestDialog({ const send = await server.sendTransaction(tx); if (send.status === "ERROR") { throw new Error( - `Soroban error: ${JSON.stringify(send.errorResult)}`, + `${t("errors.sorobanErrorPrefix")}: ${JSON.stringify(send.errorResult)}`, ); } @@ -183,18 +174,20 @@ export function InvestDialog({ form.reset({ amount: 0 }); setOpen(false); } catch (err) { - let message = - err instanceof Error - ? err.message - : t("errors.unexpectedError"); + const rawMessage = err instanceof Error ? err.message : ""; + let message = t("errors.unexpectedError"); // Check if error is due to insufficient USDC balance if ( - message.includes("resulting balance is not within the allowed range") || - message.includes("balance is not within") || - message.includes("insufficient balance") + rawMessage.includes("resulting balance is not within the allowed range") || + rawMessage.includes("balance is not within") || + rawMessage.includes("insufficient balance") ) { message = t("errors.insufficientBalance"); + } else if (rawMessage.includes(t("errors.failedAddToken"))) { + message = t("errors.failedAddToken"); + } else if (rawMessage.includes(t("errors.failedBuild"))) { + message = t("errors.failedBuild"); } setErrorMessage(message); @@ -346,7 +339,8 @@ export function InvestDialog({

- Disclaimer: {t("disclaimer")} + {t("disclaimerLabel")}:{" "} + {t("disclaimer")}

@@ -363,10 +357,6 @@ export function InvestDialog({ > {getSubmitButtonText()} - - {/*

- {t("termsAgreement")} -

*/} diff --git a/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx b/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx new file mode 100644 index 0000000..631ec6f --- /dev/null +++ b/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; +import type { GetEscrowsFromIndexerResponse } from "@trustless-work/escrow/types"; +import { ProjectCard } from "./ProjectCard"; +import type { CampaignFromApi } from "./types"; + +interface CampaignsGridProps { + campaigns: CampaignFromApi[]; + isLoading?: boolean; +} + +export function CampaignsGrid({ campaigns, isLoading }: CampaignsGridProps) { + const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); + + const escrowIds = useMemo( + () => campaigns.map((c) => c.escrowId).filter(Boolean), + [campaigns], + ); + + const { data: escrowsList, isLoading: isEscrowsLoading } = useQuery({ + queryKey: ["escrows-by-ids", escrowIds], + queryFn: async () => { + const result = await getEscrowByContractIds({ + contractIds: escrowIds, + validateOnChain: false, + }); + const list = Array.isArray(result) + ? result + : result + ? [result] + : []; + return list as GetEscrowsFromIndexerResponse[]; + }, + enabled: escrowIds.length > 0, + staleTime: 1000 * 60 * 10, + }); + + const escrowsById = useMemo(() => { + if (!escrowsList || !Array.isArray(escrowsList)) return {}; + return escrowsList.reduce( + (acc, item, idx) => { + const key = + (item as { contractId?: string })?.contractId ?? escrowIds[idx]; + if (key) acc[key] = item; + return acc; + }, + {} as Record, + ); + }, [escrowsList, escrowIds]); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+ {campaigns.map((campaign) => ( + + ))} +
+ ); +} diff --git a/apps/investor-tokenization/src/features/transparency/Carousel.tsx b/apps/investor-tokenization/src/features/transparency/Carousel.tsx deleted file mode 100644 index 0240298..0000000 --- a/apps/investor-tokenization/src/features/transparency/Carousel.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { BalanceProgressBar } from "@tokenization/tw-blocks-shared/src/escrows/indicators/balance-progress/bar/BalanceProgress"; -import { Button } from "@tokenization/ui/button"; -import { - formatCurrency, - formatTimestamp, -} from "@tokenization/tw-blocks-shared/src/helpers/format.helper"; -import type { - GetEscrowsFromIndexerResponse as Escrow, - MultiReleaseMilestone, -} from "@trustless-work/escrow/types"; -import Link from "next/link"; -import { useProjectTokenBalances } from "@/features/investments/hooks/useProjectTokenBalances.hook"; -import { Wallet, ExternalLink } from "lucide-react"; -import { fromStroops } from "@/utils/adjustedAmounts"; - -export const DummyContent = ({ - details, - tokenFactory, -}: { - details?: Escrow; - tokenFactory?: string; -}) => { - const { data: tokenBalances } = useProjectTokenBalances(); - const escrowId = details?.contractId; - const tokenBalanceInfo = escrowId ? tokenBalances?.[escrowId] : undefined; - const rawBalance = parseFloat(tokenBalanceInfo?.balance || "0"); - const tokenDecimals = tokenBalanceInfo?.tokenDecimals || 7; - const formattedBalance = rawBalance / Math.pow(10, tokenDecimals); - const tokenSymbol = tokenBalanceInfo?.tokenSymbol || "TOKEN"; - const tokenName = tokenBalanceInfo?.tokenName; - const milestones = (details?.milestones || []) as MultiReleaseMilestone[]; - - const totalAmount = milestones.reduce( - (acc, milestone) => acc + fromStroops(milestone.amount ?? 0), - 0 - ); - - return ( -
- {/* User Token Balance - Stellar Expert Style Display */} - {formattedBalance > 0 && ( -
-
-
-
- -
-
-

- {tokenName ? `${tokenName} Balance` : "Your Investment"} -

- {tokenSymbol && ( -

- {tokenSymbol} -

- )} -
-
- {tokenBalanceInfo?.tokenFactory && ( - - - - )} -
-
-

- {formattedBalance.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: tokenDecimals, - })} -

- {tokenSymbol && ( - - {tokenSymbol} - - )} -
- {tokenBalanceInfo?.tokenFactory && ( -
-

- Raw: {rawBalance.toLocaleString()} -

- - View on Stellar Expert - - -
- )} -
- )} - - {/* Info Grid */} -
- {totalAmount !== undefined && ( -
-

- Total Amount -

-

- {formatCurrency(totalAmount, details?.trustline?.symbol ?? "USDC")} -

-
- )} - - {details?.balance !== undefined && ( -
-

- Current Balance -

-

- {formatCurrency(details.balance, details?.trustline?.symbol ?? "USDC")} -

-
- )} -
- - - - {/* Metadata */} - {details?.createdAt && ( -
-

- Created: {formatTimestamp(details.createdAt)} -

-
- )} - - {details?.contractId && ( -
-

- Contract ID: {details.contractId} -

-
- )} - - {tokenFactory && ( -
-

- Token Address: {tokenFactory} -

-
- - - -
-
- )} - - {/* Milestones Section - EMPHASIZED */} - {milestones.length > 0 && ( -
-

- Milestones -

- -
- {milestones.map((milestone, index) => ( -
-
-
-
- {index + 1} -
-

- {milestone.description} -

-
- {milestone.status && ( - - {milestone.status} - - )} -
- -
- {milestone.amount !== undefined && ( -
-

- Amount -

-

- {fromStroops(milestone.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })} -

-
- )} - - {milestone.receiver && ( -
-

- Receiver -

-

- {milestone.receiver} -

-
- )} -
- - {milestone.evidence && ( -
-

- Evidence -

-

- {milestone.evidence} -

-
- )} -
- ))} -
-
- )} -
- ); -}; diff --git a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx index 247480f..6778556 100644 --- a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx +++ b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx @@ -12,9 +12,9 @@ import type { } from "@trustless-work/escrow/types"; import { InvestDialog } from "@/features/tokens/components/InvestDialog"; import { SelectedEscrowProvider } from "@/features/tokens/context/SelectedEscrowContext"; -import { getCampaignStatusConfig } from "@/features/roi/constants/campaign-status"; +import { getCampaignStatusConfig } from "@tokenization/shared"; import type { CampaignFromApi } from "./types"; -import { fromStroops } from "@/utils/adjustedAmounts"; +import { fromStroops } from "@tokenization/shared/lib/utils"; import { useTranslations } from "next-intl"; export type ProjectCardProps = { diff --git a/apps/investor-tokenization/src/hooks/use-outside-click.tsx b/apps/investor-tokenization/src/hooks/use-outside-click.tsx deleted file mode 100644 index 8121912..0000000 --- a/apps/investor-tokenization/src/hooks/use-outside-click.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useEffect } from "react"; - -export const useOutsideClick = ( - ref: React.RefObject, - callback: (event: MouseEvent | TouchEvent) => void, -) => { - useEffect(() => { - const listener = (event: MouseEvent | TouchEvent) => { - const target = event.target as Element | null; - - // Ignore clicks occurring inside any open dialog rendered via portal - // (e.g., Radix/shadcn Dialog), to avoid unintended outside-close. - if (target && target.closest('[role="dialog"]')) { - return; - } - - // DO NOTHING if the element being clicked is the target element or their children - if (!ref.current || (target && ref.current.contains(target))) { - return; - } - callback(event); - }; - - document.addEventListener("mousedown", listener); - document.addEventListener("touchstart", listener); - - return () => { - document.removeEventListener("mousedown", listener); - document.removeEventListener("touchstart", listener); - }; - }, [ref, callback]); -}; diff --git a/apps/investor-tokenization/src/lib/contractErrorHandler.ts b/apps/investor-tokenization/src/lib/contractErrorHandler.ts deleted file mode 100644 index c96101b..0000000 --- a/apps/investor-tokenization/src/lib/contractErrorHandler.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Vault Contract Error Codes - * These match the ContractError enum in the vault Rust smart contract - */ -export enum VaultContractError { - AdminNotFound = 1, - OnlyAdminCanChangeAvailability = 2, - ExchangeIsCurrentlyDisabled = 3, - BeneficiaryHasNoTokensToClaim = 4, - VaultDoesNotHaveEnoughUSDC = 5, -} - -/** - * Token Sale Contract Error Codes - * These match the ContractError enum in the token-sale Rust smart contract - */ -export enum TokenSaleContractError { - EscrowContractNotFound = 1, - ParticipationTokenNotFound = 2, - AdminNotFound = 3, - OnlyAdminCanSetToken = 4, - HardCapExceeded = 5, - InvestorCapExceeded = 6, - AmountMustBePositive = 7, -} - -const VAULT_ERROR_MESSAGES: Record = { - [VaultContractError.AdminNotFound]: "Admin not found", - [VaultContractError.OnlyAdminCanChangeAvailability]: - "Only admin can change availability", - [VaultContractError.ExchangeIsCurrentlyDisabled]: - "Exchange is currently disabled", - [VaultContractError.BeneficiaryHasNoTokensToClaim]: - "Beneficiary has no tokens to claim", - [VaultContractError.VaultDoesNotHaveEnoughUSDC]: - "Vault does not have enough USDC", -}; - -const TOKEN_SALE_ERROR_MESSAGES: Record = { - [TokenSaleContractError.EscrowContractNotFound]: - "Escrow contract not found", - [TokenSaleContractError.ParticipationTokenNotFound]: - "Participation token not found", - [TokenSaleContractError.AdminNotFound]: "Admin not found", - [TokenSaleContractError.OnlyAdminCanSetToken]: - "Only admin can set token", - [TokenSaleContractError.HardCapExceeded]: - "Hard cap exceeded – the campaign is fully funded", - [TokenSaleContractError.InvestorCapExceeded]: - "Investor cap exceeded – you have reached the maximum investment", - [TokenSaleContractError.AmountMustBePositive]: - "Amount must be positive", -}; - -const ERROR_MESSAGES_BY_CONTEXT: Record< - string, - Record -> = { - vault: VAULT_ERROR_MESSAGES, - "token-sale": TOKEN_SALE_ERROR_MESSAGES, -}; - -type TranslationFn = (key: string, values?: Record) => string; - -/** - * Extracts and maps contract error codes to user-friendly messages. - * - * When a translation function `t` is provided (from `useTranslations("contractErrors")`), - * it will use i18n keys like `t("vault.1")` or `t("tokenSale.5")`. - * Falls back to hardcoded English messages when `t` is not provided. - * - * @param error - The raw error from Soroban - * @param context - Contract context ('vault' | 'token-sale') to select the correct error map - * @param t - Optional translation function from useTranslations("contractErrors") - */ -export function extractContractError( - error: unknown, - context?: "vault" | "token-sale", - t?: TranslationFn, -): { - message: string; - details: string; -} { - const errorString = - error instanceof Error ? error.message : String(error); - - // Try to extract error code from Soroban error response - const errorCodeMatch = errorString.match(/Error\(Contract, #(\d+)\)/); - - if (errorCodeMatch) { - const errorCode = parseInt(errorCodeMatch[1], 10); - - // Try i18n first - if (t && context) { - const i18nContext = context === "token-sale" ? "tokenSale" : context; - const translatedMessage = t(`${i18nContext}.${errorCode}`); - - // next-intl returns the key path when translation is missing - if (translatedMessage && !translatedMessage.includes(`${i18nContext}.${errorCode}`)) { - return { - message: t("title"), - details: translatedMessage, - }; - } - - // Fall back to unknownCode with the code param - return { - message: t("title"), - details: t("unknownCode", { code: errorCode }), - }; - } - - // Fallback to hardcoded messages - const errorMap = context - ? ERROR_MESSAGES_BY_CONTEXT[context] - : undefined; - const humanMessage = errorMap?.[errorCode]; - - if (humanMessage) { - return { - message: "Contract Error", - details: humanMessage, - }; - } - - return { - message: "Contract Error", - details: `Contract error code ${errorCode}`, - }; - } - - // Generic error response if no specific error code found - return { - message: t ? t("title") : "Contract Error", - details: errorString, - }; -} diff --git a/apps/investor-tokenization/src/lib/sendTransactionService.ts b/apps/investor-tokenization/src/lib/sendTransactionService.ts deleted file mode 100644 index bf8ddde..0000000 --- a/apps/investor-tokenization/src/lib/sendTransactionService.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@tokenization/shared/lib/sendTransactionService"; diff --git a/apps/investor-tokenization/src/lib/sorobanClient.ts b/apps/investor-tokenization/src/lib/sorobanClient.ts deleted file mode 100644 index 65b630c..0000000 --- a/apps/investor-tokenization/src/lib/sorobanClient.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { - Keypair, - TransactionBuilder, - Address, - Contract, - rpc, - scValToNative, - nativeToScVal, - xdr, - Networks, - Operation, - hash, - Account, -} from "@stellar/stellar-sdk"; -import { randomBytes } from "crypto"; - -export type SorobanClientConfig = { - rpcUrl: string; - sourceSecret: string; - fee?: string; - timeoutSeconds?: number; - maxAttempts?: number; - pollDelayMs?: number; -}; - -// Type definitions using proper SDK types -export type AccountLike = Account; -export type ScVal = xdr.ScVal; - -// BASE_FEE constant from SDK (default minimum fee) -const BASE_FEE = "100"; - -type RequiredConfig = Required< - Pick< - SorobanClientConfig, - "fee" | "timeoutSeconds" | "maxAttempts" | "pollDelayMs" - > ->; - -export class SorobanClient { - private readonly server: rpc.Server; - private readonly keypair: Keypair; - private readonly config: RequiredConfig; - - constructor({ - rpcUrl, - sourceSecret, - fee = BASE_FEE, - timeoutSeconds = 300, - maxAttempts = 60, - pollDelayMs = 2000, - }: SorobanClientConfig) { - this.server = new rpc.Server(rpcUrl); - this.keypair = Keypair.fromSecret(sourceSecret); - this.config = { - fee, - timeoutSeconds, - maxAttempts, - pollDelayMs, - }; - } - - get publicKey() { - return this.keypair.publicKey(); - } - - nativeAddress(address: string): ScVal { - return nativeToScVal(new Address(address), { - type: "address", - }); - } - - nativeString(value: string): ScVal { - return xdr.ScVal.scvString(value); - } - - nativeU32(value: number): ScVal { - return xdr.ScVal.scvU32(value); - } - - async uploadContractWasm(wasm: Buffer, label: string) { - const result = await this.submitTransaction( - (account) => - this.buildBaseTx(account) - .addOperation( - Operation.uploadContractWasm({ - wasm, - }), - ) - .setTimeout(this.config.timeoutSeconds) - .build(), - label, - ); - - if (!result.returnValue) { - throw new Error(`${label} did not return a hash`); - } - - return Buffer.from(scValToNative(result.returnValue) as Buffer); - } - - async createContract( - wasmHash: Buffer, - constructorArgs: ScVal[], - label: string, - ) { - const result = await this.submitTransaction( - (account) => - this.buildBaseTx(account) - .addOperation( - Operation.createCustomContract({ - wasmHash, - address: new Address(this.publicKey), - salt: SorobanClient.randomSalt(), - constructorArgs, - }), - ) - .setTimeout(this.config.timeoutSeconds) - .build(), - label, - ); - - if (!result.returnValue) { - throw new Error(`${label} did not return an address`); - } - - return Address.fromScVal(result.returnValue).toString(); - } - - async callContract( - contractId: string, - method: string, - args: ScVal[], - label: string, - ) { - await this.submitTransaction((account) => { - const contract = new Contract(contractId); - return this.buildBaseTx(account) - .addOperation(contract.call(method, ...args)) - .setTimeout(this.config.timeoutSeconds) - .build(); - }, label); - } - - private buildBaseTx(account: AccountLike) { - return new TransactionBuilder(account, { - fee: this.config.fee, - networkPassphrase: Networks.TESTNET, - }); - } - - private static randomSalt() { - return hash(randomBytes(32)); - } - - /** - * Calculate a deterministic salt from a string seed - */ - calculateDeterministicSalt(seed: string): Buffer { - const seedBytes = Buffer.from(seed, "utf-8"); - return hash(seedBytes); - } - - /** - * Get contract address from salt and wasm hash (deterministic calculation) - * This is used when simulation fails because contract already exists - */ - async getContractAddressFromSalt( - wasmHash: Buffer, - salt: Buffer, - ): Promise { - // When a contract already exists, we cannot simulate its creation - // The address is deterministic, but we can't easily calculate it without the network - // The best we can do is throw a helpful error - throw new Error( - `Cannot determine address for existing contract. ` + - `Contracts are already deployed for this escrowContractId. ` + - `Please use a different escrowContractId or check if the contracts are already deployed.` - ); - } - - /** - * Create contract with a specific salt (for deterministic addresses) - */ - async createContractWithSalt( - wasmHash: Buffer, - salt: Buffer, - constructorArgs: ScVal[], - label: string, - ) { - try { - const result = await this.submitTransaction( - (account) => - this.buildBaseTx(account) - .addOperation( - Operation.createCustomContract({ - wasmHash, - address: new Address(this.publicKey), - salt, - constructorArgs, - }), - ) - .setTimeout(this.config.timeoutSeconds) - .build(), - label, - ); - - if (!result.returnValue) { - throw new Error(`${label} did not return an address`); - } - - return Address.fromScVal(result.returnValue).toString(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStr = errorMessage.toLowerCase(); - - // Check for contract already exists error (can appear in different formats) - // Also check for the special marker we added in submitTransaction - // The error can be: "HostError: Error(Storage, ExistingValue)" or "contract already exists" - const isExistingContractError = - errorMessage.includes("CONTRACT_ALREADY_EXISTS") || - errorStr.includes("contract already exists") || - errorStr.includes("existingvalue") || - (errorStr.includes("storage") && errorStr.includes("existing")) || - (errorStr.includes("hosterror") && errorStr.includes("storage") && errorStr.includes("existing")); - - if (isExistingContractError) { - console.log(`${label} already exists, attempting to get address via simulation...`); - // Try to get the address by simulating with the same parameters - try { - return await this.simulateContractCreation(wasmHash, salt, constructorArgs); - } catch (simError) { - const simErrorMsg = simError instanceof Error ? simError.message : String(simError); - const simErrorStr = simErrorMsg.toLowerCase(); - // If simulation also fails with same error, the contract definitely exists - // We can't easily get its address, so suggest using deploymentId - if (simErrorStr.includes("contract already exists") || - simErrorStr.includes("existingvalue") || - (simErrorStr.includes("storage") && simErrorStr.includes("existing"))) { - throw new Error( - `Contracts are already deployed for this escrowContractId. ` + - `To redeploy, please provide a 'deploymentId' parameter in your request ` + - `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` + - `Alternatively, use a different escrowContractId.` - ); - } - // If simulation fails for other reason, throw original error - throw error; - } - } - throw error; - } - } - - /** - * Simulate contract creation to get the address before deploying - */ - async simulateContractCreation( - wasmHash: Buffer, - salt: Buffer, - constructorArgs: ScVal[], - ): Promise { - const account = (await this.server.getAccount(this.publicKey)) as AccountLike; - - const transaction = this.buildBaseTx(account) - .addOperation( - Operation.createCustomContract({ - wasmHash, - address: new Address(this.publicKey), - salt, - constructorArgs, - }), - ) - .setTimeout(30) - .build(); - - const preparedTx = await this.server.prepareTransaction(transaction); - const simulation = await this.server.simulateTransaction(preparedTx); - - // Handle both success and error response types - if ("error" in simulation) { - const errorStr = JSON.stringify(simulation.error); - // If contract already exists, try with empty args to get address - if (errorStr.includes("contract already exists") || errorStr.includes("ExistingValue")) { - return this.getContractAddressFromSalt(wasmHash, salt); - } - throw new Error(`Simulation failed: ${errorStr}`); - } - - // Access result from success response - if (!simulation.result?.retval) { - throw new Error("Simulation did not return a contract address"); - } - - return Address.fromScVal(simulation.result.retval).toString(); - } - - private async submitTransaction( - buildTx: (account: AccountLike) => ReturnType, - label: string, - ) { - const account = (await this.server.getAccount( - this.publicKey, - )) as AccountLike; - const tx = buildTx(account); - const preparedTx = await this.server.prepareTransaction(tx); - preparedTx.sign(this.keypair); - - let sendResponse; - try { - sendResponse = await this.server.sendTransaction(preparedTx); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStr = errorMessage.toLowerCase(); - // Check if this is a "contract already exists" error from sendTransaction - if (errorStr.includes("existingvalue") || - errorStr.includes("contract already exists") || - (errorStr.includes("storage") && errorStr.includes("existing"))) { - throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`); - } - throw error; - } - - const result = await this.waitForTransaction(sendResponse.hash, label); - - if (result.status !== "SUCCESS") { - // Parse the error to extract useful information - // The error is in resultXdr for failed transactions - // Also check the entire result object for error information - let errorDetails = "Unknown error"; - if (result.resultXdr) { - errorDetails = String(result.resultXdr); - } - - // Also check if there's error information in the result object itself - const resultStr = JSON.stringify(result); - const errorMessage = `${label} failed: ${errorDetails}`; - - // Check if this is a "contract already exists" error - // The error can appear as: "Error(Storage, ExistingValue)" or "contract already exists" - // Check both errorDetails and the full result string - const errorStr = (errorDetails + " " + resultStr).toLowerCase(); - const isExistingContractError = - errorStr.includes("existingvalue") || - errorStr.includes("contract already exists") || - (errorStr.includes("storage") && errorStr.includes("existing")) || - (errorStr.includes("hosterror") && errorStr.includes("storage")); - - if (isExistingContractError) { - throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`); - } - - throw new Error(errorMessage); - } - - return result; - } - - private async waitForTransaction(hash: string, label: string) { - for (let attempt = 0; attempt < this.config.maxAttempts; attempt += 1) { - const txResult = await this.server.getTransaction(hash); - if (txResult.status === "SUCCESS" || txResult.status === "FAILED") { - return txResult; - } - // Continue polling while the transaction is not yet finalized on chain - // Some RPCs may report PENDING or NOT_FOUND until the transaction is included - - await new Promise((resolve) => - setTimeout(resolve, this.config.pollDelayMs), - ); - } - - throw new Error(`${label} timeout: transaction not found on network`); - } -} diff --git a/apps/investor-tokenization/src/lib/toastWithTx.tsx b/apps/investor-tokenization/src/lib/toastWithTx.tsx deleted file mode 100644 index 9599813..0000000 --- a/apps/investor-tokenization/src/lib/toastWithTx.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import { toast } from "sonner"; -import { Button } from "@tokenization/ui/button"; -import Link from "next/link"; - -const EXPLORER_BASE = "https://stellar.expert/explorer/testnet"; - -export function toastSuccessWithTx(message: string, txHash?: string) { - const href = txHash ? `${EXPLORER_BASE}/tx/${txHash}` : EXPLORER_BASE; - - toast.custom( - () => ( -
- {message} - - - -
- ), - { duration: 5000 } - ); -} diff --git a/apps/investor-tokenization/src/lib/tokenDeploymentService.ts b/apps/investor-tokenization/src/lib/tokenDeploymentService.ts deleted file mode 100644 index fde1e35..0000000 --- a/apps/investor-tokenization/src/lib/tokenDeploymentService.ts +++ /dev/null @@ -1,86 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { SorobanClient } from "./sorobanClient"; - -const tokenSalePath = path.join(process.cwd(), "services/wasm/token_sale.wasm"); -const tokenFactoryPath = path.join( - process.cwd(), - "services/wasm/soroban_token_contract.wasm", -); - -export type TokenDeploymentParams = { - escrowContractId: string; - tokenName: string; - tokenSymbol: string; -}; - -export type TokenDeploymentResult = { - tokenFactoryAddress: string; - tokenSaleAddress: string; -}; - -export const deployTokenContracts = async ( - client: SorobanClient, - { escrowContractId, tokenName, tokenSymbol }: TokenDeploymentParams, -): Promise => { - const tokenFactoryWasm = fs.readFileSync(tokenFactoryPath); - const tokenSaleWasm = fs.readFileSync(tokenSalePath); - - // Upload WASM files in parallel for better performance - const [tokenFactoryWasmHash, tokenSaleWasmHash] = await Promise.all([ - client.uploadContractWasm( - tokenFactoryWasm, - "TokenFactory WASM upload", - ), - client.uploadContractWasm( - tokenSaleWasm, - "TokenSale WASM upload", - ), - ]); - - // SOLUTION: Deploy TokenSale first with placeholder, then deploy TokenFactory, - // then update TokenSale with the real TokenFactory address using set_token - - // Step 1: Deploy TokenSale first with placeholder token address (deployer address) - // This allows us to get the TokenSale address for TokenFactory deployment - console.log("Deploying TokenSale..."); - const tokenSaleAddress = await client.createContract( - tokenSaleWasmHash, - [ - client.nativeAddress(escrowContractId), // escrow_contract - client.nativeAddress(client.publicKey), // sale_token (placeholder - will be updated) - client.nativeAddress(client.publicKey), // admin (deployer can update token address) - ], - "TokenSale contract creation", - ); - console.log(`TokenSale deployed at: ${tokenSaleAddress}`); - - // Step 2: Deploy TokenFactory with TokenSale address as mint_authority - console.log("Deploying TokenFactory..."); - console.log(`Deployer public address: ${client.publicKey}`); - console.log(`Mint authority: ${tokenSaleAddress}`); - const tokenFactoryAddress = await client.createContract( - tokenFactoryWasmHash, - [ - client.nativeString(tokenName), // name (user-provided) - client.nativeString(tokenSymbol), // symbol (user-provided) - client.nativeString(escrowContractId), // escrow_id - client.nativeU32(7), // decimal - client.nativeAddress(tokenSaleAddress), // mint_authority (Token Sale contract) - ], - "TokenFactory contract creation", - ); - console.log(`TokenFactory deployed at: ${tokenFactoryAddress}`); - - // Step 3: Update TokenSale with the real TokenFactory address - console.log("Updating TokenSale with correct token address..."); - await client.callContract( - tokenSaleAddress, - "set_token", - [client.nativeAddress(tokenFactoryAddress)], - "Update TokenSale token address", - ); - console.log("✅ TokenSale updated successfully with correct token address."); - - return { tokenFactoryAddress, tokenSaleAddress }; -}; diff --git a/apps/investor-tokenization/src/lib/utils.ts b/apps/investor-tokenization/src/lib/utils.ts deleted file mode 100644 index c66271b..0000000 --- a/apps/investor-tokenization/src/lib/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export { cn } from "@tokenization/shared/lib/utils"; diff --git a/apps/investor-tokenization/src/lib/vaultDeploymentService.ts b/apps/investor-tokenization/src/lib/vaultDeploymentService.ts deleted file mode 100644 index 81c8a4a..0000000 --- a/apps/investor-tokenization/src/lib/vaultDeploymentService.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import fs from "fs"; -import path from "path"; -import { SorobanClient } from "./sorobanClient"; - -const vaultContractPath = path.join( - process.cwd(), - "services/wasm/vault_contract.wasm", -); - -export type VaultDeploymentParams = { - admin: string; - enabled: boolean; - price: number | string; - token: string; - usdc: string; -}; - -export type VaultDeploymentResult = { - vaultContractAddress: string; -}; - -export const deployVaultContract = async ( - client: SorobanClient, - { admin, enabled, price, token, usdc }: VaultDeploymentParams, -): Promise => { - const vaultWasm = fs.readFileSync(vaultContractPath); - - const vaultWasmHash = await client.uploadContractWasm( - vaultWasm, - "Vault WASM upload", - ); - - const vaultContractAddress = await client.createContract( - vaultWasmHash, - [ - client.nativeAddress(admin), - StellarSDK.nativeToScVal(enabled, { type: "bool" }), - StellarSDK.nativeToScVal(price, { type: "i128" }), - client.nativeAddress(token), - client.nativeAddress(usdc), - ], - "Vault contract creation", - ); - - return { vaultContractAddress }; -}; diff --git a/apps/investor-tokenization/src/utils/adjustedAmounts.ts b/apps/investor-tokenization/src/utils/adjustedAmounts.ts deleted file mode 100644 index 4d53f89..0000000 --- a/apps/investor-tokenization/src/utils/adjustedAmounts.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Decimal from "decimal.js"; - -const USDC_DECIMAL_SCALE = 1e7; - -export function adjustPricesToMicroUSDC(price: number): string { - if (!Number.isFinite(price) || price < 0) { - throw new Error("Price must be a finite, non-negative number"); - } - - const priceDecimal = new Decimal(price.toString()); - const microUSDCDecimal = priceDecimal.times(USDC_DECIMAL_SCALE); - - const rounded = microUSDCDecimal.toDecimalPlaces(0, Decimal.ROUND_HALF_EVEN); - - return rounded.toFixed(0); -} - -export function fromStroops(stroops: number | string): number { - const val = new Decimal(String(stroops)); - return val.div(USDC_DECIMAL_SCALE).toNumber(); -} diff --git a/docs/postman-flows.md b/docs/POSTMAN.md similarity index 100% rename from docs/postman-flows.md rename to docs/POSTMAN.md diff --git a/docs/REFACTOR_BACKOFFICE_INVESTOR.md b/docs/REFACTOR_BACKOFFICE_INVESTOR.md new file mode 100644 index 0000000..f3ebe19 --- /dev/null +++ b/docs/REFACTOR_BACKOFFICE_INVESTOR.md @@ -0,0 +1,187 @@ +# Refactor backlog (Backoffice e Investor) + +Generado a partir de una revisión de `apps/backoffice-tokenization`, `apps/investor-tokenization` y código compartido relevante (`packages/*`), siguiendo `rules/DAPPS.mdc`. + +## Top oportunidades (priorizadas por esfuerzo) + +### 1) I18n: eliminar strings hardcodeadas en diálogos y toasts +**Esfuerzo:** S +**Impacto:** mejora consistencia UX, reduce “mezcla de idiomas” y evita mensajes no traducidos. +**Evidencia:** +- `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx` (hardcode en `DialogTitle`, `DialogDescription`, `toast.success(...)`, botón y spinner). +- `apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts` (hardcode en mensajes de error/toast). +- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` (errores lanzados/derivados desde strings hardcodeadas; algunos son mostrados directamente al usuario). +**Propuesta:** +1. Pasar todas las strings de UI y errores “usuario-facing” por `useTranslations(...)` (o por el sistema de mensajes equivalente ya existente). +2. Mantener el “fallback” solo para errores verdaderamente inesperados (idealmente también traducible). +**Riesgo:** bajo; requiere solo refactor de mapeo de mensajes, sin tocar lógica de transacción. + +--- + +### 2) Formularios: reemplazar validación manual por `react-hook-form` + `zod` +**Esfuerzo:** S +**Impacto:** menor riesgo de edge cases, consistencia entre apps, menos lógica ad-hoc por input. +**Evidencia:** +- `apps/backoffice-tokenization/src/features/campaigns/components/roi/FundRoiDialog.tsx` (validación con `Number(amount)` en `handleSubmit`). +- `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx` (validación manual con rangos `0..100`). +**Propuesta:** +1. Crear schemas `zod` para cada formulario (cantidad / porcentaje). +2. Implementar hooks `useForm` y `handleSubmit` en un hook dedicado (o directamente dentro del componente si es trivial). +3. Garantizar que los mensajes de error salgan por `FormMessage`/UI estándar. +**Riesgo:** bajo/medio; revisar compatibilidad con el estado actual (por ejemplo reseteo de `amount`/`percentage` cuando se cierra el diálogo). + +--- + +### 3) Dead code / duplicación de UI: componentes de “campañas” que parecen no usarse +**Esfuerzo:** S +**Impacto:** menos superficie de mantenimiento y menos confusión sobre cuál patrón usar. +**Evidencia / motivo de sospecha:** +- Backoffice mantiene `apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx` + `campaign-toolbar.tsx` + `campaign-search.tsx` + `campaign-filter.tsx`, pero los pages usan `SharedCampaignsView` de `packages/features`. +- Investor mantiene `apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx` + `campaign-search.tsx` + `campaign-filter.tsx`, pero en `my-investments/page.tsx` se usa `SharedCampaignsView` del paquete (que ya trae toolbar). +**Propuesta:** +1. Confirmar por búsqueda de imports (y/o tests/compilación) que no se usan. +2. Si no se usan: eliminar o convertir en componentes “legacy” claramente marcados. +3. Si se usan en algún route no cubierto: re-engancharlos a `packages/features` para evitar bifurcar el UX. +**Riesgo:** bajo si se valida con build/compilación; medio si existen rutas “raras” no cubiertas. + +--- + +### 4) Eliminar `any` y `eslint-disable` en `ManageLoansView` +**Esfuerzo:** M +**Impacto:** robustez (tipado real), reduce riesgo de fallos silenciosos y mejora mantenibilidad. +**Evidencia:** +- `apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx`: + - `eslint-disable-next-line @typescript-eslint/no-explicit-any` + - `const data = (...) as any;` +**Propuesta:** +1. Tipar la respuesta de `getEscrowByContractIds` (o envolverla con un mapper seguro). +2. Evitar asumir `data[0]` sin validaciones de forma (`Array.isArray` + guard clauses). +3. Remover el `eslint-disable` una vez el tipo quede expresado. +**Riesgo:** medio; requiere entender el tipo real que retorna el indexer. + +--- + +### 5) Investor: optimizar invalidación/refetch y manejo de errores en `InvestDialog` +**Esfuerzo:** M +**Impacto:** performance (menos requests duplicadas), mejora UX ante errores, menos “cascadas” de estado. +**Evidencia:** +- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx`: + - secuencia repetida: + - `invalidateQueries(...)` y luego `refetchQueries(...)` para las mismas queryKeys (`balanceQueryKey`, `singleEscrowKey`, `["escrows-by-ids"]`). + - `console.error("Failed to save investment to database:", dbError);` (no necesariamente usuario-facing). + - parseo de errores por `message.includes(...)` (heurística frágil). +**Propuesta:** +1. Mantener solo `invalidateQueries(...)` (usualmente suficiente con React Query) o definir `staleTime`/config para evitar refetch inmediato. +2. Centralizar el mapeo de errores de Soroban (o los del core) hacia mensajes traducidos (en vez de heurísticas por substrings). +3. Convertir `console.error` a un logger consistente o manejar el error con toast cuando afecte al usuario. +**Riesgo:** medio; requiere validar comportamiento del cache en la pantalla (carousel/balance/progreso). + +--- + +### 6) Reducir complejidad O(n) y mapeos repetidos en páginas +**Esfuerzo:** S +**Impacto:** baja pero limpia performance y simplifica lógica. +**Evidencia:** +- `apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx`: + - `handleClaimRoi` usa `campaigns.find(...)` en cada click. + - `aggregateByCampaign` realiza una agregación correcta con `Map`, pero el `find` puede volverse O(n) por interacción. +**Propuesta:** +1. Mantener un `Map` memoizado por `campaigns` para resolver por id en O(1). +2. (Opcional) similar para backoffice si hay “find/filter” repetidos. +**Riesgo:** bajo. + +--- + +### 7) Consolidar flujo transaccional duplicado (ROI + enable/disable vault) +**Esfuerzo:** M +**Impacto:** gran mejora de mantenibilidad y reducción de bugs por divergencia. +**Evidencia (patrón repetido):** +- Backoffice: + - `apps/backoffice-tokenization/src/features/campaigns/hooks/useFundRoi.ts` + - `apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts` + - `apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts` + - `apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts` +- Investor: + - `apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts` (builder -> sign -> submit -> toast) + - `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` también incluye sign+submit, aunque con lógica adicional. +**Comparación rápida del patrón:** +1. Validar wallet conectado. +2. Construir `unsignedXdr`. +3. `signTransaction({ unsignedTransaction, address })`. +4. Enviar: + - Backoffice: vía `submitSignedTransactionAndWait(...)` (RPC directo) -> parse extra. + - Investor: vía `SendTransactionService` (API helper). +5. Actualizar queries / toasts. +**Propuesta (a nivel de diseño):** +1. Crear un hook/utility compartido en `packages/*` (posiblemente `packages/shared` o `packages/tw-blocks-shared`) tipo: + - `useExecuteSignedXdr(...)` o `executeSignedXdr(...)` +2. Parámetros configurables: + - `buildUnsignedXdr: () => Promise` + - `submitMode: "directRpc" | "api"` + - `onSuccess`, `onError`, `queryInvalidations` (opcional) +3. Reutilizarlo en todos los hooks anteriores. +**Riesgo:** medio; hay dos modos de envío (direct RPC vs API), pero el diseño puede unificarse con adaptadores. + +--- + +### 8) Consolidar UI de “CampaignCard” entre Backoffice e Investor +**Esfuerzo:** L +**Impacto:** reduce duplicación de lógica, asegura consistencia visual y minimiza divergencias. +**Evidencia (duplicación):** +- Backoffice: `apps/backoffice-tokenization/src/components/shared/campaign-card.tsx` +- Investor: `apps/investor-tokenization/src/components/shared/campaign-card.tsx` +Ambos renderizan: +- status badge (via `getCampaignStatusConfig`) +- milestones (misma lógica de flags `approved/released`) +- query para obtener escrow milestones/balance (misma estructura de `useQuery` por card) +**Propuesta:** +1. Extraer a `packages/features` o `packages/ui` una `CampaignCard` parametrizable: + - `cardMode: "backoffice" | "investor"` + - callbacks: `onManageLoans?(id)`, `onClaimRoi?(id)` o `actionsSlot` + - label/formatters de footer (balance/poolSize) +2. Mantener la lógica compartida de milestones y el “fetch de escrow” en un solo lugar. +**Riesgo:** medio/alto; requiere definir API de componentes y validar el mapping entre los tipos de campaña (backoffice vs investor). + +--- + +### 9) Modularizar componentes monolíticos: `ManageLoansView` e `InvestDialog` +**Esfuerzo:** L +**Impacto:** reduce complejidad ciclomática y facilita testing/iteración. +**Evidencia:** +- `apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx` (fetch + approve/release + dialogs + add milestone en un solo componente). +- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` (wizard trustline/buy + múltiples renders + invalidations + persistencia + cálculo estimaciones). +**Propuesta:** +1. Extraer subcomponentes: + - “Milestones list” + - “Approve/Release action” + - “Change status dialog” + - “Add milestone form” +2. Extraer hooks: + - `useManageLoans` / `useInvestFlow` para orquestación de pasos + estado. +3. Mantener componentes de UI “presentacionales” (según `DAPPS.mdc`). +**Riesgo:** alto; aunque es refactor, tocará props/estado y requiere buena regresión manual. + +--- + +## Roadmap sugerido + +### Fase 1 (rápida, bajo riesgo) +- I18n: eliminar hardcode en `UpdateRoiDialog` y mensajes usuario-facing. +- Migrar `FundRoiDialog` + `UpdateRoiDialog` a `react-hook-form` + `zod`. +- Remover dead code de toolbars/views duplicadas que no se usan. +- Pequeñas optimizaciones (Map para lookups en `my-investments/page.tsx`). + +### Fase 2 (mantenibilidad + performance) +- Quitar `any`/`eslint-disable` en `ManageLoansView`. +- Reducir invalidation/refetch duplicado en `InvestDialog`. +- Diseñar/introducir abstracción de ejecución transaccional (sin implementarla aún si se requiere una coordinación fina). + +### Fase 3 (consolidación fuerte) +- Unificar `CampaignCard` en `packages/*`. +- Modularizar `ManageLoansView` e `InvestDialog` con hooks/subcomponents. +- Extraer una capa unificada de “submit signed transaction + onSuccess/onError + toast” para ambos apps. + +## Candidatos “de apoyo” (verificar) +- `packages/shared/src/lib/contractErrorHandler.ts`: revisar si está muerto. + - `extractContractError` no parece referenciarse desde `apps/backoffice-tokenization` ni `apps/investor-tokenization`. + diff --git a/docs/TOKENIZE-ESCROW.md b/docs/TOKENIZE-ESCROW.md index 35a3f1c..f936c54 100644 --- a/docs/TOKENIZE-ESCROW.md +++ b/docs/TOKENIZE-ESCROW.md @@ -6,8 +6,7 @@ This document maps the current tokenize escrow flow, identifying all components, ## File Paths ### Backoffice UI Components -- **Route/Page**: `apps/backoffice-tokenization/src/app/manage-escrows/page.tsx` -- **View Component**: `apps/backoffice-tokenization/src/features/manage-escrows/ManageEscrowsView.tsx` +- **UI Route/Page**: _Not currently wired in `apps/backoffice-tokenization/src/app`_ - **Dialog Component**: `apps/backoffice-tokenization/src/features/tokens/deploy/dialog/TokenizeEscrow.tsx` - **Hook**: `apps/backoffice-tokenization/src/features/tokens/deploy/dialog/useTokenizeEscrow.ts` - **Service**: `apps/backoffice-tokenization/src/features/tokens/services/token.service.ts` @@ -44,9 +43,8 @@ Form values are typed in `useTokenizeEscrow.ts` as `TokenizeEscrowFormValues` an ``` User (Browser) │ - ├─> [1] Navigate to /manage-escrows - │ └─> ManageEscrowsView.tsx - │ └─> TokenizeEscrowDialog (Button trigger) + ├─> [1] Navigate to the UI entry point (TBD) + │ └─> TokenizeEscrowDialog (Button trigger) │ ├─> [2] User clicks "Tokenize Escrow" button │ └─> Opens TokenizeEscrowDialog diff --git a/docs/TOKEN_BALANCE_SYSTEM.md b/docs/TOKEN_BALANCE_SYSTEM.md index 78c43b3..17fa05d 100644 --- a/docs/TOKEN_BALANCE_SYSTEM.md +++ b/docs/TOKEN_BALANCE_SYSTEM.md @@ -21,11 +21,10 @@ All paths below are relative to `apps/investor-tokenization/`. 3. **Hooks** (`src/features/investments/hooks/`) - `useUserInvestments` — Fetches user's investments with balance > 0 - - `useProjectTokenBalances` — Fetches balances for all projects (used by carousel/home) 4. **Components** - - `src/features/investments/components/` — `InvestmentCard`, `InvestmentsView` - - `src/features/transparency/` — `ProjectList`, `Carousel` (use `useProjectTokenBalances` for balances on cards) + - `src/features/investments/components/` — `InvestmentCard` + - `src/features/transparency/` — `ProjectList` (no dedicated carousel component) ## How Token Balance Reading Works @@ -226,37 +225,6 @@ Fetches all investments where the user has a token balance > 0. - Includes token metadata - Caches results for 2 minutes -### useProjectTokenBalances - -Fetches token balances for all projects (used in carousel/home page). - -**Returns:** -```typescript -{ - data: Record, - isLoading: boolean, - isError: boolean -} -``` - -**ProjectTokenBalanceInfo Type:** -```typescript -{ - escrowId: string, - tokenFactory: string, - balance: string, - tokenName?: string, - tokenSymbol?: string, - tokenDecimals?: number -} -``` - -**Features:** -- Fetches balances for all projects in parallel -- Returns a map of `escrowId -> balance info` -- Includes token metadata -- Used to display balances on project cards - ## Balance Formatting ### Raw vs Formatted Balance @@ -286,12 +254,9 @@ formattedBalance.toLocaleString(undefined, { ## Project Data Structure -The list of projects used for **balance checks** is defined in the hooks (duplicated in each hook that needs it): - -- `useProjectTokenBalances.hook.ts` — `PROJECT_DATA` (used for carousel/home balances) -- `useUserInvestments.hook.ts` — same `PROJECT_DATA` (used for investments view) +The list of projects used for **balance checks** is defined in the hooks: -The **carousel UI** gets project cards from `ProjectList.tsx` (`data` array with `escrowId`, `tokenSale`, `tokenFactory`, and optional `src`/`content`). The balances shown on each card come from `useProjectTokenBalances()`, which calls the token-balance API for each project in `PROJECT_DATA`. +- `useUserInvestments.hook.ts` — `PROJECT_DATA` **Example PROJECT_DATA (in hooks):** diff --git a/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md b/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md deleted file mode 100644 index b1e4817..0000000 --- a/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md +++ /dev/null @@ -1,302 +0,0 @@ -# Security Audit: VaultContract - -**Date:** March 2025 -**Contract:** `vault-contract` -**Scope:** Audit report. -**Author:** [@Villarley](https://github.com/Villarley) - ---- - -## Summary Table - -| Severity | Count | Findings | -|----------|-------|----------| -| **Critical** | 2 | F-01, F-02 | -| **High** | 3 | F-03, F-04, F-05 | -| **Medium** | 5 | F-06, F-07, F-08, F-09, F-10 | -| **Low** | 3 | F-11, F-12, F-13 | -| **Informational** | 2 | F-14, F-15 | -| **Enhancement** | 1 | F-17 (Scout) | - -**Scout (static analysis):** 4 critical, 5 medium, 1 enhancement. See [Scout Findings](#scout-findings) section. - ---- - -## Executive Summary - -The following files of the `vault-contract` were audited in their current state: - -- [`vault.rs`](../apps/smart-contracts/contracts/vault-contract/src/vault.rs) — main contract logic -- [`error.rs`](../apps/smart-contracts/contracts/vault-contract/src/error.rs) — typed errors -- [`events.rs`](../apps/smart-contracts/contracts/vault-contract/src/events.rs) — event emission -- [`storage_types.rs`](../apps/smart-contracts/contracts/vault-contract/src/storage_types.rs) — storage keys -- [`test.rs`](../apps/smart-contracts/contracts/vault-contract/src/test.rs) — contract tests - ---- - -## Findings by Severity - -### CRITICAL - -#### F-01: Missing TTL Extension in Storage - -| Field | Details | -|-------|---------| -| **Severity** | Critical | -| **Description** | The contract does not call `extend_ttl` in any operation. In Soroban, instance storage expires if not extended periodically. The `token-factory` contract does extend TTL on every operation that modifies or reads state (see [`contract.rs:86-89`](../apps/smart-contracts/contracts/token-factory/src/contract.rs)). | -| **Impact** | If TTL expires, the vault state (admin, enabled, roi_percentage, token_address, usdc_address, total_tokens_redeemed) is lost. USDC funds would be trapped in the contract address with no way to recover them. | -| **Recommendation** | Add `env.storage().instance().extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT)` in all functions that read or write storage, following the token-factory pattern. Define constants similar to token-factory's `storage_types.rs`. | - ---- - -#### F-02: Arithmetic Overflow in Claim Formula - -| Field | Details | -|-------|---------| -| **Severity** | Critical | -| **Description** | The expression `token_balance * (100 + roi_percentage) / 100` (lines 172 and 304) uses unverified arithmetic. With extreme values it can cause `i128` overflow. | -| **Impact** | Overflow can cause panic or incorrect results. Although amounts are typically bounded in practice, an attacker with very large tokens or a misconfigured ROI could exploit this. | -| **Recommendation** | Use `checked_mul` and `checked_div`. Add `ArithmeticOverflow` variant to `ContractError` and validate in the constructor that `roi_percentage` is within a reasonable range (e.g. 0–1000). | - ---- - -### HIGH - -#### F-03: Constructor Without Input Validation - -| Field | Details | -|-------|---------| -| **Severity** | High | -| **Description** | `__constructor` (lines 60-80) does not validate parameters. It accepts: negative or extreme `roi_percentage` (e.g. `i128::MAX`); arbitrary `admin`, `token`, `usdc` addresses (including malicious contracts); `token == usdc` (same contract for token and USDC). | -| **Impact** | Vault deployed with invalid or malicious configuration; loss of funds or unexpected behavior. | -| **Recommendation** | Validate in constructor: `0 <= roi_percentage <= ROI_MAX`; `admin`, `token`, `usdc` are not zero address; `token != usdc`. Optionally verify that `token` and `usdc` implement the expected interface. | - ---- - -#### F-04: Use of `.expect()` in Critical Paths - -| Field | Details | -|-------|---------| -| **Severity** | High | -| **Description** | There are 14 uses of `.expect()` in `vault.rs` (lines 146, 156, 162, 177, 221, 237, 245, 253, 262, 297, 314, 343, 361, 367). If the key does not exist (corrupt storage, expired TTL, incorrect migration), the contract panics. | -| **Impact** | Runtime panic; failed transaction; possible gas/fee loss without useful message for the user. | -| **Recommendation** | Replace with `ok_or(ContractError::XxxNotFound)?` in functions that return `Result`. In getters that return values directly, document that they assume initialized state and consider returning `Result` or a safe default where appropriate. | - ---- - -#### F-05: Overflow in `TotalTokensRedeemed` - -| Field | Details | -|-------|---------| -| **Severity** | High | -| **Description** | At line 198: `total_redeemed + token_balance` can overflow `i128` if the accumulated total is very large. | -| **Impact** | Overflow → panic; counter desynchronized from actual state if wrapping arithmetic were used. | -| **Recommendation** | Use `checked_add` and propagate the error with `ContractError::ArithmeticOverflow`. | - ---- - -### MEDIUM - -#### F-06: `preview_claim` vs `claim` Divergence on Negative ROI / Underflow in `roi_amount` - -| Field | Details | -|-------|---------| -| **Severity** | Medium (Scout: CRITICAL for underflow) | -| **Description** | `preview_claim` uses `unwrap_or(0)` for `roi_percentage` (line 291), while `claim` uses `expect` (line 156). With negative ROI, `roi_amount = usdc_amount - token_balance` (line 308) can cause **underflow** if `usdc_amount < token_balance`. Scout: `[CRITICAL] This subtraction operation could underflow`. | -| **Impact** | Underflow → panic; preview can show values inconsistent with actual `claim` result in edge cases. | -| **Recommendation** | Use `checked_sub` for `roi_amount`. Unify storage handling and validate `roi_percentage >= 0` in constructor to avoid negative ROI. | - ---- - -#### F-07: Events Without `#[contractevent]` - -| Field | Details | -|-------|---------| -| **Severity** | Medium | -| **Description** | Events are emitted with `env.events().publish((symbol_short!("claim"),), event)` instead of the `#[contractevent]` macro recommended by the SDK. The escrow contract uses `#[contractevent]` in [`events/handler.rs`](../apps/smart-contracts/contracts/escrow/src/events/handler.rs). | -| **Impact** | Events are not included in the contract spec; indexers and clients lack auto-generated types; reduced interoperability. | -| **Recommendation** | Migrate to `#[contractevent]` with appropriate topics and `#[topic]` on key fields (beneficiary, etc.) for indexing. | - ---- - -#### F-08: No Validation of External Contract Addresses - -| Field | Details | -|-------|---------| -| **Severity** | Medium | -| **Description** | The `token` and `usdc` addresses are stored without verifying they are valid token contracts. A malicious admin or deployment error could point to malicious contracts. | -| **Impact** | Malicious contracts could implement callbacks in `burn`/`transfer` and cause reentrancy or incorrect logic. | -| **Recommendation** | Document that the deployer must use trusted addresses. Optionally: if the ecosystem provides a token registry, validate against it. Assume the admin is trusted. | - ---- - -#### F-09: Authorization Pattern in `availability_for_exchange` - -| Field | Details | -|-------|---------| -| **Severity** | Medium | -| **Description** | The function receives `admin` as a parameter and calls `admin.require_auth()` followed by `admin != stored_admin`. Scout: `[MEDIUM] Usage of admin parameter might be unnecessary` — suggests retrieving admin from storage instead of passing it as a parameter. | -| **Impact** | The pattern is correct. An attacker cannot impersonate the admin without the keys. Best practice is to obtain admin from storage; current design is acceptable but can be improved for clarity. | -| **Recommendation** | Read `stored_admin` first and use `stored_admin.require_auth()` to make explicit that the stored admin is authorized, not the parameter. Remove the `admin` parameter if the signature allows. | - ---- - -#### F-10: Constructor Re-invocation - -| Field | Details | -|-------|---------| -| **Severity** | Medium | -| **Description** | There is no explicit protection against a second invocation of `__constructor`. In Soroban the constructor runs on deployment; re-invocation depends on the upgrade/redeploy model. | -| **Impact** | If the constructor could be called again in some upgrade flow, all state would be overwritten. | -| **Recommendation** | Add an initialization flag (like token-factory's `write_escrow_id`/`write_mint_authority`) that panics if already initialized. Document expected behavior on upgrades. | - ---- - -### LOW - -#### F-11: Use of `i128` for Non-negative Amounts - -| Field | Details | -|-------|---------| -| **Severity** | Low | -| **Description** | Balances, amounts, and ROI are represented with `i128`. Financial amounts are typically non-negative. | -| **Impact** | Allows negative values that are later rejected at runtime; increases surface for sign-related errors. | -| **Recommendation** | Consider newtypes or early validation. Short term: validate `amount >= 0` in constructor for `roi_percentage` and any amount input. | - ---- - -#### F-12: Inconsistency Between `unwrap_or` and `expect` in Getters - -| Field | Details | -|-------|---------| -| **Severity** | Low | -| **Description** | `is_enabled` and `get_total_tokens_redeemed` use `unwrap_or(false)`/`unwrap_or(0)`; other getters use `expect`. Inconsistent behavior when storage is empty. | -| **Impact** | Makes it harder to reason about contract state when data is missing. | -| **Recommendation** | Define a clear policy: either all getters assume initialized state (and use `expect` with clear messages), or they return `Result`/documented default values consistently. | - ---- - -#### F-13: `preview_claim` Does Not Return `Result` - -| Field | Details | -|-------|---------| -| **Severity** | Low | -| **Description** | `preview_claim` returns `ClaimPreview` directly. If storage read fails (e.g. `expect` on token_address or usdc_address), it panics. | -| **Impact** | A read-only function can panic instead of returning a controlled error. | -| **Recommendation** | Change signature to `Result` and propagate errors, or document that it assumes correctly initialized contract. | - ---- - -### INFORMATIONAL - -#### F-14: Additional Tests Recommended - -| Field | Details | -|-------|---------| -| **Severity** | Informational | -| **Description** | Current tests do not explicitly cover: overflow in claim formula; negative ROI in constructor; behavior with expired TTL (when TTL is implemented); reentrancy scenarios (if custom tokens are used). | -| **Impact** | Lower coverage of edge cases and attack scenarios. | -| **Recommendation** | Add tests for overflow, constructor validation, and (when applicable) TTL and reentrancy. | - ---- - -#### F-15: Code Quality and `no_std` - -| Field | Details | -|-------|---------| -| **Severity** | Informational | -| **Description** | The crate uses `#![no_std]` correctly. Tests use `extern crate std` only under `#[cfg(test)]`, which is appropriate. | -| **Impact** | None significant. | -| **Recommendation** | Ensure no dependencies introduce `std` into the production binary. | - ---- - -## Claim Flow Diagram and Failure Points - -```mermaid -flowchart TD - subgraph claim [claim] - A[require_auth beneficiary] --> B[Read enabled, roi, token, usdc] - B --> C[Get token_balance] - C --> D{balance > 0?} - D -->|No| E[Err BeneficiaryHasNoTokensToClaim] - D -->|Yes| F["usdc_amount = balance * (100+roi)/100"] - F --> G{Overflow?} - G -->|Yes| H[Panic] - G -->|No| I{vault_usdc >= usdc_amount?} - I -->|No| J[Err VaultDoesNotHaveEnoughUSDC] - I -->|Yes| K[token_client.burn] - K --> L[usdc_client.transfer] - L --> M[total_redeemed += token_balance] - M --> N{Overflow?} - N -->|Yes| O[Panic] - N -->|No| P[Emit event] - end -``` - ---- - -## Related Open Issues - -No open issues were found in the repository that explicitly address these findings. - ---- - -## Report Acceptance Criteria - -| Criterion | Status | -|----------|--------| -| Each finding includes severity, description, impact, and recommendation | Met | -| Findings classified by severity (Critical / High / Medium / Low / Informational) | Met | -| Indication of related open issues | Met (none existing) | -| Claim flow diagram with failure points | Met | - ---- - -## Scout Findings - -Results from static analysis with [Scout](https://github.com/stellar/scout) (Soroban security linter): - -| Scout Severity | Location | Description | Correlation | -|----------------|----------|-------------|-------------| -| **CRITICAL** | `vault.rs:171` | Overflow/underflow in `token_balance * (100 + roi_percentage) / 100` | F-02 | -| **CRITICAL** | `vault.rs:198` | Overflow in `total_redeemed + token_balance` | F-05 | -| **CRITICAL** | `vault.rs:303` | Overflow/underflow in `preview_claim` formula | F-02 | -| **CRITICAL** | `vault.rs:308` | Underflow in `usdc_amount - token_balance` | F-06 | -| **MEDIUM** | `vault.rs:142` | Unsafe `expect` — Enabled flag | F-04 | -| **MEDIUM** | `vault.rs:152` | Unsafe `expect` — ROI percentage | F-04 | -| **MEDIUM** | `vault.rs:158` | Unsafe `expect` — Token address | F-04 | -| **MEDIUM** | `vault.rs:173` | Unsafe `expect` — USDC address | F-04 | -| **MEDIUM** | `vault.rs:96` | Admin parameter unnecessary; retrieve from storage | F-09 | -| **ENHANCEMENT** | — | Soroban version: 23.1.1 → 25.3.0 | New | - -### Scout Summary - -``` -+----------------+----------+----------+--------+-------+-------------+ -| Crate | Status | Critical | Medium | Minor | Enhancement | -+----------------+----------+----------+--------+-------+-------------+ -| vault_contract | Analyzed | 4 | 5 | 0 | 1 | -+----------------+----------+----------+--------+-------+-------------+ -``` - -### F-17: Outdated Soroban Version (Scout) - -| Field | Details | -|-------|---------| -| **Severity** | Enhancement | -| **Description** | The project uses Soroban 23.1.1. The latest version is 25.3.0. Scout: `#[warn(soroban_version)]`. | -| **Impact** | Possible missing security patches and SDK improvements in the latest versions. | -| **Recommendation** | Update the `soroban-sdk` dependency in the workspace to 25.3.0 (or latest stable). Review changelog for breaking changes. | - ---- - -## References - -- [Soroban Security Best Practices — Stellar Developers](https://developers.stellar.org/docs/smart-contracts/guides/security) -- [Soroban SDK — Storage, TTL and Archiving](https://developers.stellar.org/docs/smart-contracts/guides/storage) -- [Soroban SDK — Authorization and require_auth](https://developers.stellar.org/docs/smart-contracts/guides/authorization) -- [Soroban SDK — Events with #[contractevent]](https://developers.stellar.org/docs/smart-contracts/guides/events) -- [Rust — Checked arithmetic: checked_*, saturating_*, wrapping_*](https://doc.rust-lang.org/std/primitive.i128.html#method.checked_add) -- [OWASP Smart Contract Top 10](https://owasp.org/www-project-smart-contract-top-10/) -- [SWC Registry — Smart Contract Weakness Classification](https://swcregistry.io/) diff --git a/docs/plans/2026-03-13-update-roi-percentage.md b/docs/plans/2026-03-13-update-roi-percentage.md deleted file mode 100644 index 2b14878..0000000 --- a/docs/plans/2026-03-13-update-roi-percentage.md +++ /dev/null @@ -1,398 +0,0 @@ -# Update ROI Percentage — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Allow backoffice admins to update the ROI percentage of a campaign's vault from the `/roi` page via an actions dropdown menu. - -**Architecture:** Add a dropdown-menu UI component, an API call function, a hook (following `useToggleVault` pattern), and a dialog (following `FundRoiDialog` pattern). Replace inline action buttons in the ROI table row with a dropdown menu that groups all actions. - -**Tech Stack:** React 19, Next.js 16, Radix UI (dropdown-menu), TanStack React Query, Soroban wallet signing, sonner toasts. - ---- - -### Task 1: Add DropdownMenu UI component - -**Files:** -- Create: `packages/ui/src/dropdown-menu.tsx` - -**Step 1: Install Radix dropdown-menu dependency** - -Run from repo root: -```bash -cd packages/ui && npm install @radix-ui/react-dropdown-menu -``` - -**Step 2: Create the component** - -Create `packages/ui/src/dropdown-menu.tsx` exporting Radix primitives styled consistently with the existing dialog/popover components. Follow the same pattern as `packages/ui/src/dialog.tsx` — thin wrappers around Radix with Tailwind classes. - -Exports needed: -- `DropdownMenu` (Root) -- `DropdownMenuTrigger` -- `DropdownMenuContent` -- `DropdownMenuItem` -- `DropdownMenuSeparator` - -**Step 3: Export from package index** - -Verify the package uses path-based exports (e.g., `@tokenization/ui/dropdown-menu`). Check `packages/ui/package.json` exports field and add entry if needed. - -**Step 4: Verify build** - -Run: `npx turbo run build --filter=@tokenization/ui` -Expected: BUILD SUCCESS - -**Step 5: Commit** - -```bash -git add packages/ui/src/dropdown-menu.tsx packages/ui/package.json -git commit -m "feat(ui): add DropdownMenu component" -``` - ---- - -### Task 2: Add API call function - -**Files:** -- Modify: `apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts` - -**Step 1: Add `updateRoiPorcentage` function** - -Add to the bottom of `campaigns.api.ts`: - -```typescript -export async function updateRoiPorcentage(params: { - contractId: string; - newRoiPorcentage: number; - callerPublicKey: string; -}): Promise<{ unsignedXdr: string }> { - const { data } = await httpClient.post<{ unsignedXdr: string }>( - "/vault/update-roi-porcentage", - params, - ); - return data; -} -``` - -This calls the existing backend endpoint `POST /vault/update-roi-porcentage` which accepts `{ contractId, newRoiPorcentage, callerPublicKey }` and returns `{ unsignedXdr }`. - -**Step 2: Commit** - -```bash -git add apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts -git commit -m "feat: add updateRoiPorcentage API call" -``` - ---- - -### Task 3: Add `useUpdateRoiPercentage` hook - -**Files:** -- Create: `apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts` - -**Step 1: Create the hook** - -Follow the exact pattern of `useToggleVault.ts`: - -```typescript -"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/campaigns/services/soroban.service"; -import { updateRoiPorcentage } from "@/features/campaigns/services/campaigns.api"; - -interface UseUpdateRoiPercentageParams { - onSuccess?: () => void; -} - -export function useUpdateRoiPercentage({ onSuccess }: UseUpdateRoiPercentageParams = {}) { - const { walletAddress } = useWalletContext(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - const execute = async (vaultContractId: string, newRoiPorcentage: number) => { - if (!walletAddress) { - setError("Wallet not connected"); - return; - } - - setError(null); - setIsSubmitting(true); - - try { - const { unsignedXdr } = await updateRoiPorcentage({ - contractId: vaultContractId, - newRoiPorcentage, - 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 }; -} -``` - -**Step 2: Commit** - -```bash -git add apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts -git commit -m "feat: add useUpdateRoiPercentage hook" -``` - ---- - -### Task 4: Add `UpdateRoiDialog` component - -**Files:** -- Create: `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx` - -**Step 1: Create the dialog** - -Follow the exact pattern of `FundRoiDialog.tsx`: - -```typescript -"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 { useUpdateRoiPercentage } from "@/features/campaigns/hooks/useUpdateRoiPercentage"; -import { toast } from "sonner"; - -interface UpdateRoiDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - campaignName: string; - vaultId: string; - onUpdated: () => void; -} - -export function UpdateRoiDialog({ - open, - onOpenChange, - campaignName, - vaultId, - onUpdated, -}: UpdateRoiDialogProps) { - const [percentage, setPercentage] = useState(""); - - const { execute, isSubmitting, error } = useUpdateRoiPercentage({ - onSuccess: () => { - toast.success("ROI percentage updated successfully"); - onUpdated(); - onOpenChange(false); - setPercentage(""); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const parsed = Number(percentage); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return; - execute(vaultId, parsed); - }; - - return ( - - - - Update ROI Percentage — {campaignName} - - Set a new ROI percentage for this campaign's vault. Value must be between 0 and 100. - - - -
-
- - setPercentage(e.target.value)} - disabled={isSubmitting} - autoComplete="off" - /> -
- -

- Vault:{" "} - {vaultId} -

- - {error ? ( -

{error}

- ) : null} - - -
-
-
- ); -} -``` - -**Step 2: Commit** - -```bash -git add apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx -git commit -m "feat: add UpdateRoiDialog component" -``` - ---- - -### Task 5: Replace inline buttons with actions dropdown in ROI table row - -**Files:** -- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx` -- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/types.ts` - -**Step 1: Update types to support the new callback** - -Add `onUpdateRoi` callback to `RoiTableRowProps` in `types.ts`: - -```typescript -export interface RoiTableRowProps { - campaign: Campaign; - balance: number; - onAddFunds: (campaign: Campaign) => void; - onUpdateRoi: (campaign: Campaign) => void; -} -``` - -Also add to `RoiTableProps`: - -```typescript -export interface RoiTableProps { - campaigns: Campaign[]; - onAddFunds: (campaign: Campaign) => void; - onUpdateRoi: (campaign: Campaign) => void; -} -``` - -**Step 2: Refactor `roi-table-row.tsx`** - -Replace the inline buttons in the `` with a `DropdownMenu`. The dropdown trigger is an "Actions" button with a `MoreHorizontal` icon. Menu items: -- "Gestionar Prestamos" (link to loans page) -- "Toggle Vault" (enable/disable) -- "Subir Fondos" (calls `onAddFunds`) -- Separator -- "Actualizar ROI" (calls `onUpdateRoi`) - -Key imports to add: -```typescript -import { MoreHorizontal } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@tokenization/ui/dropdown-menu"; -``` - -**Step 3: Pass `onUpdateRoi` through `roi-table.tsx`** - -Update `RoiTable` to accept and forward `onUpdateRoi` prop to each `RoiTableRow`. - -**Step 4: Verify build** - -Run: `npx turbo run build --filter=backoffice-tokenization` -Expected: BUILD SUCCESS - -**Step 5: Commit** - -```bash -git add apps/backoffice-tokenization/src/features/campaigns/components/roi/ -git commit -m "feat: replace inline buttons with actions dropdown in ROI table" -``` - ---- - -### Task 6: Wire up UpdateRoiDialog in roi-view.tsx - -**Files:** -- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-view.tsx` -- Modify: `apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts` - -**Step 1: Add ROI dialog state to `use-roi.ts` hook** - -Add state management for the Update ROI dialog following the same pattern as `fundsDialogCampaign`: - -```typescript -const [roiDialogCampaign, setRoiDialogCampaign] = useState(null); -const [roiDialogOpen, setRoiDialogOpen] = useState(false); - -function openRoiDialog(campaign: Campaign) { - setRoiDialogCampaign(campaign); - setRoiDialogOpen(true); -} - -function closeRoiDialog() { - setRoiDialogOpen(false); - setRoiDialogCampaign(null); -} -``` - -Return all new values from the hook. - -**Step 2: Update `roi-view.tsx`** - -- Import `UpdateRoiDialog` -- Destructure `roiDialogCampaign`, `roiDialogOpen`, `openRoiDialog`, `closeRoiDialog` from `useRoi()` -- Pass `onUpdateRoi={openRoiDialog}` to `` -- Render `` alongside `` in the dialogs section - -**Step 3: Verify build** - -Run: `npx turbo run build --filter=backoffice-tokenization` -Expected: BUILD SUCCESS - -**Step 4: Commit** - -```bash -git add apps/backoffice-tokenization/src/features/campaigns/components/roi/ apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts -git commit -m "feat: wire UpdateRoiDialog into ROI view" -``` diff --git a/package-lock.json b/package-lock.json index f14f89d..61442a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10449,6 +10449,10 @@ "resolved": "packages/config", "link": true }, + "node_modules/@tokenization/features": { + "resolved": "packages/features", + "link": true + }, "node_modules/@tokenization/shared": { "resolved": "packages/shared", "link": true @@ -26361,9 +26365,24 @@ "name": "@tokenization/config", "version": "0.0.0" }, + "packages/features": { + "name": "@tokenization/features", + "version": "0.0.0", + "dependencies": { + "@tokenization/shared": "*", + "@tokenization/ui": "*", + "react": "19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.6" + } + }, "packages/shared": { "name": "@tokenization/shared", - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "@stellar/stellar-sdk": "^14.3.3" + } }, "packages/tw-blocks-shared": { "name": "@tokenization/tw-blocks-shared", @@ -26373,7 +26392,8 @@ "name": "@tokenization/ui", "version": "0.0.0", "dependencies": { - "@radix-ui/react-dropdown-menu": "^2.1.16" + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@tokenization/shared": "*" } } } diff --git a/packages/features/package.json b/packages/features/package.json new file mode 100644 index 0000000..73bf4f7 --- /dev/null +++ b/packages/features/package.json @@ -0,0 +1,18 @@ +{ + "name": "@tokenization/features", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./campaign": "./src/campaign/index.ts" + }, + "dependencies": { + "@tokenization/shared": "*", + "@tokenization/ui": "*", + "react": "19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.6" + } +} diff --git a/packages/features/src/campaign/campaign-toolbar.tsx b/packages/features/src/campaign/campaign-toolbar.tsx new file mode 100644 index 0000000..d6b00a5 --- /dev/null +++ b/packages/features/src/campaign/campaign-toolbar.tsx @@ -0,0 +1,92 @@ +"use client"; + +import type { SharedCampaignStatus } from "@tokenization/shared/src/types/campaign"; + +const STATUS_OPTIONS: { value: SharedCampaignStatus | "all"; labelKey: string }[] = [ + { value: "all", labelKey: "filterAll" }, + { value: "FUNDRAISING", labelKey: "filterFundraising" }, + { value: "ACTIVE", labelKey: "filterActive" }, + { value: "REPAYMENT", labelKey: "filterRepayment" }, + { value: "CLAIMABLE", labelKey: "filterClaimable" }, + { value: "CLOSED", labelKey: "filterClosed" }, +]; + +export interface CampaignToolbarLabels { + searchPlaceholder: string; + filterAll: string; + filterFundraising: string; + filterActive: string; + filterRepayment: string; + filterClaimable: string; + filterClosed: string; +} + +export interface CampaignToolbarProps { + searchValue: string; + onSearchChange: (value: string) => void; + filterValue: SharedCampaignStatus | "all"; + onFilterChange: (value: SharedCampaignStatus | "all") => void; + labels: CampaignToolbarLabels; +} + +export function CampaignToolbar({ + searchValue, + onSearchChange, + filterValue, + onFilterChange, + labels, +}: CampaignToolbarProps) { + const labelByKey: Record = { + filterAll: labels.filterAll, + filterFundraising: labels.filterFundraising, + filterActive: labels.filterActive, + filterRepayment: labels.filterRepayment, + filterClaimable: labels.filterClaimable, + filterClosed: labels.filterClosed, + }; + + return ( +
+
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+
+
+ + + + onSearchChange(e.target.value)} + className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+
+ ); +} diff --git a/packages/features/src/campaign/campaigns-view.tsx b/packages/features/src/campaign/campaigns-view.tsx new file mode 100644 index 0000000..df842b3 --- /dev/null +++ b/packages/features/src/campaign/campaigns-view.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { SectionTitle } from "@tokenization/ui/section-title"; +import type { SharedCampaign, SharedCampaignStatus } from "@tokenization/shared/src/types/campaign"; +import { CampaignToolbar } from "./campaign-toolbar"; +import type { CampaignToolbarLabels } from "./campaign-toolbar"; + +export interface SharedCampaignsViewProps { + title: string; + description: string; + campaigns: SharedCampaign[]; + isLoading?: boolean; + loadingMessage?: string; + emptyMessage?: string; + headerActions?: React.ReactNode; + toolbarLabels: CampaignToolbarLabels; + children: (filteredCampaigns: SharedCampaign[]) => React.ReactNode; +} + +export function SharedCampaignsView({ + title, + description, + campaigns, + isLoading = false, + loadingMessage, + emptyMessage, + headerActions, + toolbarLabels, + children, +}: SharedCampaignsViewProps) { + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + + 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]); + + return ( +
+
+ + {headerActions != null ?
{headerActions}
: null} +
+ + {isLoading ? ( +

+ {loadingMessage ?? ""} +

+ ) : filteredCampaigns.length === 0 ? ( +
+

{emptyMessage ?? ""}

+
+ ) : ( + children(filteredCampaigns) + )} +
+ ); +} diff --git a/packages/features/src/campaign/index.ts b/packages/features/src/campaign/index.ts new file mode 100644 index 0000000..45c1c92 --- /dev/null +++ b/packages/features/src/campaign/index.ts @@ -0,0 +1,2 @@ +export * from "./campaign-toolbar"; +export * from "./campaigns-view"; diff --git a/packages/features/src/index.ts b/packages/features/src/index.ts new file mode 100644 index 0000000..d662da8 --- /dev/null +++ b/packages/features/src/index.ts @@ -0,0 +1 @@ +export * from "./campaign"; diff --git a/packages/features/tsconfig.json b/packages/features/tsconfig.json new file mode 100644 index 0000000..64d9c1f --- /dev/null +++ b/packages/features/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 70bd35a..e7278ac 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,6 +7,15 @@ ".": "./src/index.ts", "./lib/utils": "./src/lib/utils.ts", "./lib/sendTransactionService": "./src/lib/sendTransactionService.ts", - "./lib/httpClient": "./src/lib/httpClient.ts" + "./lib/httpClient": "./src/lib/httpClient.ts", + "./lib/sorobanClient": "./src/lib/sorobanClient.ts", + "./lib/sorobanSubmitAndWait": "./src/lib/sorobanSubmitAndWait.ts", + "./lib/contractErrorHandler": "./src/lib/contractErrorHandler.ts", + "./lib/vaultDeploymentService": "./src/lib/vaultDeploymentService.ts", + "./lib/constants": "./src/lib/constants.ts", + "./src/types/campaign": "./src/types/campaign.ts" + }, + "dependencies": { + "@stellar/stellar-sdk": "^14.3.3" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d61b66c..476ce60 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,8 @@ export * from "./lib/utils"; export * from "./lib/sendTransactionService"; export * from "./lib/httpClient"; +export * from "./lib/sorobanClient"; +export * from "./lib/sorobanSubmitAndWait"; +export * from "./lib/contractErrorHandler"; +export * from "./lib/constants"; +export * from "./lib/campaignStatus"; diff --git a/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts b/packages/shared/src/lib/campaignStatus.ts similarity index 64% rename from apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts rename to packages/shared/src/lib/campaignStatus.ts index 1a413bf..ff82627 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts +++ b/packages/shared/src/lib/campaignStatus.ts @@ -1,8 +1,15 @@ -import type { CampaignStatus } from "@/features/campaigns/types/campaign.types"; +import type { SharedCampaignStatus } from "../types/campaign"; +export type CampaignStatusConfig = { label: string; className: string }; + +/** + * Returns a consistent status config for campaign badges (labels + Tailwind classes). + * Pass your campaigns namespace translator, e.g. useTranslations("campaigns"). + * Translation keys: status.DRAFT, status.FUNDRAISING, status.ACTIVE, etc. + */ export function getCampaignStatusConfig( t: (key: string) => string, -): Record { +): Record { return { DRAFT: { label: t("status.DRAFT"), className: "bg-secondary text-text-muted border-border" }, FUNDRAISING: { label: t("status.FUNDRAISING"), className: "bg-blue-50 text-blue-600 border-blue-200" }, diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts new file mode 100644 index 0000000..440d236 --- /dev/null +++ b/packages/shared/src/lib/constants.ts @@ -0,0 +1,28 @@ +const USDC_ADDRESS = + process.env.NEXT_PUBLIC_DEFAULT_USDC_ADDRESS || + ""; + +const SOROBAN_RPC_URL = + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || + ""; + +const NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE || + ""; + +const ESCROW_EXPLORER_URL = "https://viewer.trustlesswork.com/"; + +function getContractExplorerUrl(contractId: string): string { + const base = NETWORK_PASSPHRASE?.toLowerCase().includes("test") + ? "https://stellar.expert/explorer/testnet/contract" + : "https://stellar.expert/explorer/public/contract"; + return `${base}/${contractId}`; +} + +export { + USDC_ADDRESS, + SOROBAN_RPC_URL, + NETWORK_PASSPHRASE, + ESCROW_EXPLORER_URL, + getContractExplorerUrl, +}; \ No newline at end of file diff --git a/apps/backoffice-tokenization/src/lib/contractErrorHandler.ts b/packages/shared/src/lib/contractErrorHandler.ts similarity index 96% rename from apps/backoffice-tokenization/src/lib/contractErrorHandler.ts rename to packages/shared/src/lib/contractErrorHandler.ts index 84467a8..08d32f8 100644 --- a/apps/backoffice-tokenization/src/lib/contractErrorHandler.ts +++ b/packages/shared/src/lib/contractErrorHandler.ts @@ -66,6 +66,11 @@ const CONTEXT_TO_TRANSLATION_KEY: Record = { "token-sale": "tokenSale", }; +export type ContractErrorTranslationFn = ( + key: string, + values?: Record, +) => string; + /** * Extracts and maps contract error codes to user-friendly messages. * @@ -80,7 +85,7 @@ const CONTEXT_TO_TRANSLATION_KEY: Record = { export function extractContractError( error: unknown, context?: "vault" | "token-sale", - t?: (key: string, values?: Record) => string, + t?: ContractErrorTranslationFn, ): { message: string; details: string; diff --git a/packages/shared/src/lib/sendTransactionService.ts b/packages/shared/src/lib/sendTransactionService.ts index 22e2ae7..1d0c879 100644 --- a/packages/shared/src/lib/sendTransactionService.ts +++ b/packages/shared/src/lib/sendTransactionService.ts @@ -11,9 +11,7 @@ export type SendTransactionResponse = { }; export type SendTransactionServiceOptions = { - /** Core API base URL (e.g. http://localhost:4000). When not set, uses NEXT_PUBLIC_CORE_API_URL or fallback "/api". */ baseURL?: string; - /** API key for core API (x-api-key). Required when baseURL points to core. */ apiKey?: string; }; @@ -31,17 +29,13 @@ export class SendTransactionService { const env = typeof process !== "undefined" ? process.env : ({} as NodeJS.ProcessEnv); - // Prefer explicit option, then server-side secrets, then public envs let apiKey = options.apiKey?.trim() || - // When running on the server (Next.js SSR / route handlers), prefer - // the same secrets that the Core API uses in its ApiKeyGuard (typeof window === "undefined" ? env.BACKOFFICE_API_KEY?.trim() || - env.INVESTORS_API_KEY?.trim() || - "" + env.INVESTORS_API_KEY?.trim() || + "" : "") || - // Fallback to public envs for purely browser-side usage env.NEXT_PUBLIC_API_KEY?.trim() || env.NEXT_PUBLIC_INVESTORS_API_KEY?.trim() || env.NEXT_PUBLIC_BACKOFFICE_API_KEY?.trim() || diff --git a/apps/backoffice-tokenization/src/lib/sorobanClient.ts b/packages/shared/src/lib/sorobanClient.ts similarity index 80% rename from apps/backoffice-tokenization/src/lib/sorobanClient.ts rename to packages/shared/src/lib/sorobanClient.ts index 4ba7492..c4f104b 100644 --- a/apps/backoffice-tokenization/src/lib/sorobanClient.ts +++ b/packages/shared/src/lib/sorobanClient.ts @@ -174,8 +174,8 @@ export class SorobanClient { // The best we can do is throw a helpful error throw new Error( `Cannot determine address for existing contract. ` + - `Contracts are already deployed for this escrowContractId. ` + - `Please use a different escrowContractId or check if the contracts are already deployed.` + `Contracts are already deployed for this escrowContractId. ` + + `Please use a different escrowContractId or check if the contracts are already deployed.`, ); } @@ -211,37 +211,49 @@ export class SorobanClient { return Address.fromScVal(result.returnValue).toString(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); const errorStr = errorMessage.toLowerCase(); - + // Check for contract already exists error (can appear in different formats) // Also check for the special marker we added in submitTransaction // The error can be: "HostError: Error(Storage, ExistingValue)" or "contract already exists" - const isExistingContractError = + const isExistingContractError = errorMessage.includes("CONTRACT_ALREADY_EXISTS") || - errorStr.includes("contract already exists") || + errorStr.includes("contract already exists") || errorStr.includes("existingvalue") || (errorStr.includes("storage") && errorStr.includes("existing")) || - (errorStr.includes("hosterror") && errorStr.includes("storage") && errorStr.includes("existing")); - + (errorStr.includes("hosterror") && + errorStr.includes("storage") && + errorStr.includes("existing")); + if (isExistingContractError) { - console.log(`${label} already exists, attempting to get address via simulation...`); + console.log( + `${label} already exists, attempting to get address via simulation...`, + ); // Try to get the address by simulating with the same parameters try { - return await this.simulateContractCreation(wasmHash, salt, constructorArgs); + return await this.simulateContractCreation( + wasmHash, + salt, + constructorArgs, + ); } catch (simError) { - const simErrorMsg = simError instanceof Error ? simError.message : String(simError); + const simErrorMsg = + simError instanceof Error ? simError.message : String(simError); const simErrorStr = simErrorMsg.toLowerCase(); // If simulation also fails with same error, the contract definitely exists // We can't easily get its address, so suggest using deploymentId - if (simErrorStr.includes("contract already exists") || - simErrorStr.includes("existingvalue") || - (simErrorStr.includes("storage") && simErrorStr.includes("existing"))) { + if ( + simErrorStr.includes("contract already exists") || + simErrorStr.includes("existingvalue") || + (simErrorStr.includes("storage") && simErrorStr.includes("existing")) + ) { throw new Error( `Contracts are already deployed for this escrowContractId. ` + - `To redeploy, please provide a 'deploymentId' parameter in your request ` + - `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` + - `Alternatively, use a different escrowContractId.` + `To redeploy, please provide a 'deploymentId' parameter in your request ` + + `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` + + `Alternatively, use a different escrowContractId.`, ); } // If simulation fails for other reason, throw original error @@ -260,8 +272,10 @@ export class SorobanClient { salt: Buffer, constructorArgs: ScVal[], ): Promise { - const account = (await this.server.getAccount(this.publicKey)) as AccountLike; - + const account = (await this.server.getAccount( + this.publicKey, + )) as AccountLike; + const transaction = this.buildBaseTx(account) .addOperation( Operation.createCustomContract({ @@ -281,7 +295,10 @@ export class SorobanClient { if ("error" in simulation) { const errorStr = JSON.stringify(simulation.error); // If contract already exists, try with empty args to get address - if (errorStr.includes("contract already exists") || errorStr.includes("ExistingValue")) { + if ( + errorStr.includes("contract already exists") || + errorStr.includes("ExistingValue") + ) { return this.getContractAddressFromSalt(wasmHash, salt); } throw new Error(`Simulation failed: ${errorStr}`); @@ -305,22 +322,25 @@ export class SorobanClient { const tx = buildTx(account); const preparedTx = await this.server.prepareTransaction(tx); preparedTx.sign(this.keypair); - + let sendResponse; try { sendResponse = await this.server.sendTransaction(preparedTx); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); const errorStr = errorMessage.toLowerCase(); // Check if this is a "contract already exists" error from sendTransaction - if (errorStr.includes("existingvalue") || - errorStr.includes("contract already exists") || - (errorStr.includes("storage") && errorStr.includes("existing"))) { + if ( + errorStr.includes("existingvalue") || + errorStr.includes("contract already exists") || + (errorStr.includes("storage") && errorStr.includes("existing")) + ) { throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`); } throw error; } - + const result = await this.waitForTransaction(sendResponse.hash, label); if (result.status !== "SUCCESS") { @@ -331,25 +351,25 @@ export class SorobanClient { if (result.resultXdr) { errorDetails = String(result.resultXdr); } - + // Also check if there's error information in the result object itself const resultStr = JSON.stringify(result); const errorMessage = `${label} failed: ${errorDetails}`; - + // Check if this is a "contract already exists" error // The error can appear as: "Error(Storage, ExistingValue)" or "contract already exists" // Check both errorDetails and the full result string const errorStr = (errorDetails + " " + resultStr).toLowerCase(); - const isExistingContractError = - errorStr.includes("existingvalue") || + const isExistingContractError = + errorStr.includes("existingvalue") || errorStr.includes("contract already exists") || (errorStr.includes("storage") && errorStr.includes("existing")) || (errorStr.includes("hosterror") && errorStr.includes("storage")); - + if (isExistingContractError) { throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`); } - + throw new Error(errorMessage); } @@ -361,17 +381,21 @@ export class SorobanClient { for (let attempt = 0; attempt < this.config.maxAttempts; attempt += 1) { try { const txResult = await this.server.getTransaction(hash); - + if (txResult.status === "SUCCESS" || txResult.status === "FAILED") { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`${label} completed after ${elapsed}s (attempt ${attempt + 1})`); + console.log( + `${label} completed after ${elapsed}s (attempt ${attempt + 1})`, + ); return txResult; } - + // Log progress every 10 attempts if (attempt > 0 && attempt % 10 === 0) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`${label} still pending... (${elapsed}s elapsed, attempt ${attempt + 1}/${this.config.maxAttempts})`); + console.log( + `${label} still pending... (${elapsed}s elapsed, attempt ${attempt + 1}/${this.config.maxAttempts})`, + ); } } catch (error) { // If transaction not found, continue polling (it might not be included yet) @@ -379,10 +403,13 @@ export class SorobanClient { // This is expected during early polling, continue } else { // Log unexpected errors but continue polling - console.warn(`${label} polling error (attempt ${attempt + 1}):`, error instanceof Error ? error.message : String(error)); + console.warn( + `${label} polling error (attempt ${attempt + 1}):`, + error instanceof Error ? error.message : String(error), + ); } } - + // Continue polling while the transaction is not yet finalized on chain // Some RPCs may report PENDING or NOT_FOUND until the transaction is included await new Promise((resolve) => @@ -391,12 +418,15 @@ export class SorobanClient { } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - const maxWaitTime = ((this.config.maxAttempts * this.config.pollDelayMs) / 1000).toFixed(1); + const maxWaitTime = ( + (this.config.maxAttempts * this.config.pollDelayMs) / + 1000 + ).toFixed(1); throw new Error( `${label} timeout after ${elapsed}s (max wait: ${maxWaitTime}s). ` + - `Transaction hash: ${hash}. ` + - `The transaction may still be processing on the network. ` + - `Please check the transaction status manually or try again later.` + `Transaction hash: ${hash}. ` + + `The transaction may still be processing on the network. ` + + `Please check the transaction status manually or try again later.`, ); } } diff --git a/packages/shared/src/lib/sorobanSubmitAndWait.ts b/packages/shared/src/lib/sorobanSubmitAndWait.ts new file mode 100644 index 0000000..a0094ff --- /dev/null +++ b/packages/shared/src/lib/sorobanSubmitAndWait.ts @@ -0,0 +1,48 @@ +import { rpc, TransactionBuilder, Networks } from "@stellar/stellar-sdk"; + +const DEFAULT_POLL_ATTEMPTS = 30; +const DEFAULT_POLL_DELAY_MS = 2000; + +export type SubmitSignedTransactionAndWaitOptions = { + rpcUrl: string; + networkPassphrase?: string; + pollAttempts?: number; + pollDelayMs?: number; +}; + +/** + * Submits a signed transaction XDR to the Soroban RPC and polls until the + * transaction is finalized (SUCCESS or FAILED). Throws on send error or timeout. + */ +export async function submitSignedTransactionAndWait( + signedXdr: string, + options: SubmitSignedTransactionAndWaitOptions, +): Promise { + const { + rpcUrl, + networkPassphrase = Networks.TESTNET, + pollAttempts = DEFAULT_POLL_ATTEMPTS, + pollDelayMs = DEFAULT_POLL_DELAY_MS, + } = options; + + const server = new rpc.Server(rpcUrl); + const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + const send = await server.sendTransaction(tx); + if (send.status === "ERROR") { + throw new Error(`Soroban error: ${JSON.stringify(send.errorResult)}`); + } + + let result: rpc.Api.GetTransactionResponse | undefined; + for (let i = 0; i < pollAttempts; i++) { + await new Promise((r) => setTimeout(r, pollDelayMs)); + result = await server.getTransaction(send.hash); + if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) break; + } + + if (!result || result.status === rpc.Api.GetTransactionStatus.NOT_FOUND) { + throw new Error(`Transaction TIMEOUT`); + } + + return result; +} diff --git a/packages/shared/src/lib/utils.ts b/packages/shared/src/lib/utils.ts index 193828b..460a6aa 100644 --- a/packages/shared/src/lib/utils.ts +++ b/packages/shared/src/lib/utils.ts @@ -1,8 +1,32 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +const ALLOWED_KEYS = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"]; +const SOROBAN_DECIMAL_SCALE = 1e7; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function fromStroops(stroops: number | string): number { + return Number(stroops) / SOROBAN_DECIMAL_SCALE; +} +import type { KeyboardEvent } from "react"; + +export function numericInputKeyDown(e: KeyboardEvent) { + if ( + !/[0-9.,]/.test(e.key) && + !ALLOWED_KEYS.includes(e.key) && + !e.ctrlKey && + !e.metaKey + ) { + e.preventDefault(); + } +} + +export function parseNumericInput(value: string, max?: number): number { + const num = Number(value.replace(/[^0-9.,]/g, "").replace(",", ".")); + const safe = isNaN(num) ? 0 : num; + return max !== undefined ? Math.min(safe, max) : safe; +} diff --git a/packages/shared/src/types/campaign.ts b/packages/shared/src/types/campaign.ts new file mode 100644 index 0000000..acffd94 --- /dev/null +++ b/packages/shared/src/types/campaign.ts @@ -0,0 +1,22 @@ +export type SharedCampaignStatus = + | "DRAFT" + | "FUNDRAISING" + | "ACTIVE" + | "REPAYMENT" + | "CLAIMABLE" + | "CLOSED" + | "PAUSED"; + +export interface SharedCampaign { + id: string; + title: string; + description: string; + status: SharedCampaignStatus; + loansCompleted: number; + investedAmount: number; + currency: string; + vaultId: string | null; + escrowId: string; + poolSize: number; +} + diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx index c1dceac..6288086 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx @@ -20,13 +20,7 @@ import { Entities } from "./Entities"; import { GeneralInformation } from "./GeneralInformation"; import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider"; import { SuccessReleaseDialog } from "./SuccessReleaseDialog"; - -/** - * Based on the provided roles -> https://docs.trustlesswork.com/trustless-work/technology-overview/roles-in-trustless-work - * - * The roles that the user assigns in the escrow initialization are in the userRolesInEscrow state. Based on these roles, you'll have different actions buttons. - * - */ +import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants"; interface EscrowDetailDialogProps { isDialogOpen: boolean; @@ -55,7 +49,7 @@ export const EscrowDetailDialog = ({ selectedEscrow, }); - const viewerUrl = `https://viewer.trustlesswork.com/${selectedEscrow?.contractId}`; + const viewerUrl = `${ESCROW_EXPLORER_URL}${selectedEscrow?.contractId}`; if (!isDialogOpen || !selectedEscrow) return null; return ( diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx index 5c13ffc..d88db9b 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx @@ -7,7 +7,7 @@ import { TooltipTrigger, TooltipContent, } from "@tokenization/ui/tooltip"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { MultiReleaseMilestone } from "@trustless-work/escrow"; import { Ban, diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx index 6327bcf..cd13034 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx @@ -3,7 +3,7 @@ import React from "react"; import type { ReactNode } from "react"; import type { LucideIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { Button } from "@tokenization/ui/button"; import { Card, CardContent } from "@tokenization/ui/card"; import { Badge } from "@tokenization/ui/badge"; diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx index c1dceac..6288086 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx @@ -20,13 +20,7 @@ import { Entities } from "./Entities"; import { GeneralInformation } from "./GeneralInformation"; import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider"; import { SuccessReleaseDialog } from "./SuccessReleaseDialog"; - -/** - * Based on the provided roles -> https://docs.trustlesswork.com/trustless-work/technology-overview/roles-in-trustless-work - * - * The roles that the user assigns in the escrow initialization are in the userRolesInEscrow state. Based on these roles, you'll have different actions buttons. - * - */ +import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants"; interface EscrowDetailDialogProps { isDialogOpen: boolean; @@ -55,7 +49,7 @@ export const EscrowDetailDialog = ({ selectedEscrow, }); - const viewerUrl = `https://viewer.trustlesswork.com/${selectedEscrow?.contractId}`; + const viewerUrl = `${ESCROW_EXPLORER_URL}${selectedEscrow?.contractId}`; if (!isDialogOpen || !selectedEscrow) return null; return ( diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx index 712cea8..8b58245 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from "react"; import { Card } from "@tokenization/ui/card"; import { Tooltip, TooltipTrigger, TooltipContent } from "@tokenization/ui/tooltip"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { MultiReleaseMilestone } from "@trustless-work/escrow"; import { Ban, diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx index 6327bcf..cd13034 100644 --- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx +++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx @@ -3,7 +3,7 @@ import React from "react"; import type { ReactNode } from "react"; import type { LucideIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; import { Button } from "@tokenization/ui/button"; import { Card, CardContent } from "@tokenization/ui/card"; import { Badge } from "@tokenization/ui/badge"; diff --git a/packages/tw-blocks-shared/src/helpers/format.helper.ts b/packages/tw-blocks-shared/src/helpers/format.helper.ts index 35f0c22..79b3d65 100644 --- a/packages/tw-blocks-shared/src/helpers/format.helper.ts +++ b/packages/tw-blocks-shared/src/helpers/format.helper.ts @@ -5,8 +5,11 @@ * @param currency - The currency * @returns The formatted currency */ -export const formatCurrency = (value: number, currency: string) => { - return `${currency} ${value.toFixed(2)}`; +export const formatCurrency = (value: number | string, currency: string) => { + const numericValue = typeof value === "string" ? Number(value) : value; + const safeValue = Number.isFinite(numericValue) ? numericValue : 0; + + return `${currency} ${safeValue.toFixed(2)}`; }; /** diff --git a/packages/ui/package.json b/packages/ui/package.json index 50412f9..d466484 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,9 +38,13 @@ "./app-sidebar": "./src/app-sidebar.tsx", "./hooks/use-mobile": "./src/hooks/use-mobile.ts", "./sidebar-wallet-button": "./src/sidebar-wallet-button.tsx", - "./campaign-card": "./src/campaign-card.tsx" + "./campaign-card": "./src/campaign-card.tsx", + "./toast-with-tx": "./src/toast-with-tx.tsx", + "./language-switcher": "./src/language-switcher.tsx", + "./section-title": "./src/section-title.tsx" }, "dependencies": { - "@radix-ui/react-dropdown-menu": "^2.1.16" + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@tokenization/shared": "*" } } diff --git a/packages/ui/src/app-sidebar.tsx b/packages/ui/src/app-sidebar.tsx index c2e81f9..33703c8 100644 --- a/packages/ui/src/app-sidebar.tsx +++ b/packages/ui/src/app-sidebar.tsx @@ -47,6 +47,8 @@ export interface AppSidebarProps extends React.ComponentProps { logo: AppSidebarLogoConfig footerItems?: AppSidebarFooterItem[] footerContent?: React.ReactNode + /** Pathname for active state (e.g. from next-intl usePathname). If not set, uses next/navigation usePathname(). */ + pathname?: string } export function AppSidebar({ @@ -54,9 +56,11 @@ export function AppSidebar({ logo, footerItems, footerContent, + pathname: pathnameProp, ...sidebarProps }: AppSidebarProps) { - const pathname = usePathname() + const nextPathname = usePathname() + const pathname = pathnameProp ?? nextPathname const logoHref = logo.href ?? "/" return ( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9117218..02fb895 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -26,5 +26,7 @@ export * from "./table"; export * from "./tabs"; export * from "./textarea"; export * from "./tooltip"; +export * from "./language-switcher"; +export * from "./section-title"; diff --git a/apps/backoffice-tokenization/src/components/shared/language-switcher.tsx b/packages/ui/src/language-switcher.tsx similarity index 50% rename from apps/backoffice-tokenization/src/components/shared/language-switcher.tsx rename to packages/ui/src/language-switcher.tsx index 9264917..1430f6d 100644 --- a/apps/backoffice-tokenization/src/components/shared/language-switcher.tsx +++ b/packages/ui/src/language-switcher.tsx @@ -1,22 +1,32 @@ "use client"; import { useLocale } from "next-intl"; -import { useRouter, usePathname } from "@/i18n/navigation"; -import { cn } from "@/lib/utils"; +import { cn } from "@tokenization/shared/lib/utils"; -const locales = [ - { code: "es" as const, label: "ES" }, - { code: "en" as const, label: "EN" }, -]; +export type LanguageSwitcherLocale = "es" | "en"; -export function LanguageSwitcher() { +export interface LanguageSwitcherProps { + pathname: string; + onSwitchLocale: (next: LanguageSwitcherLocale) => void; + locales?: ReadonlyArray<{ code: LanguageSwitcherLocale; label: string }>; +} + +const defaultLocales: ReadonlyArray<{ code: LanguageSwitcherLocale; label: string }> = + [ + { code: "es", label: "ES" }, + { code: "en", label: "EN" }, + ]; + +export function LanguageSwitcher({ + pathname: _pathname, + onSwitchLocale, + locales = defaultLocales, +}: LanguageSwitcherProps) { const locale = useLocale(); - const router = useRouter(); - const pathname = usePathname(); - function switchLocale(next: "es" | "en") { + function switchLocale(next: LanguageSwitcherLocale) { if (next === locale) return; - router.replace(pathname, { locale: next }); + onSwitchLocale(next); } return ( @@ -38,3 +48,4 @@ export function LanguageSwitcher() {
); } + diff --git a/apps/investor-tokenization/src/components/shared/section-title.tsx b/packages/ui/src/section-title.tsx similarity index 99% rename from apps/investor-tokenization/src/components/shared/section-title.tsx rename to packages/ui/src/section-title.tsx index 2889b55..d94c853 100644 --- a/apps/investor-tokenization/src/components/shared/section-title.tsx +++ b/packages/ui/src/section-title.tsx @@ -13,3 +13,4 @@ export function SectionTitle({ title, description }: SectionTitleProps) {
); } + diff --git a/apps/backoffice-tokenization/src/lib/toastWithTx.tsx b/packages/ui/src/toast-with-tx.tsx similarity index 85% rename from apps/backoffice-tokenization/src/lib/toastWithTx.tsx rename to packages/ui/src/toast-with-tx.tsx index c356d14..71592a2 100644 --- a/apps/backoffice-tokenization/src/lib/toastWithTx.tsx +++ b/packages/ui/src/toast-with-tx.tsx @@ -1,6 +1,8 @@ +"use client"; + import { toast } from "sonner"; -import { Button } from "@tokenization/ui/button"; -import { Link } from "@/i18n/navigation"; +import { Button } from "./button"; +import Link from "next/link"; const EXPLORER_BASE = "https://stellar.expert/explorer/testnet"; @@ -22,6 +24,6 @@ export function toastSuccessWithTx(
), - { duration: 5000 } + { duration: 5000 }, ); }