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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/investor-tokenization/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export default function RootLayout({
children: ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="light" suppressHydrationWarning>
<body
className={cn(
Exo2.variable,
"antialiased dark",
"antialiased",
spaceGrotesk.className,
)}
>
Expand Down
6 changes: 6 additions & 0 deletions apps/investor-tokenization/src/app/roi/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { RoiDashboardShell } from "@/features/roi/roi-dashboard-shell";
import { ReactNode } from "react";

export default function RoiLayout({ children }: { children: ReactNode }) {
return <RoiDashboardShell>{children}</RoiDashboardShell>;
}
40 changes: 40 additions & 0 deletions apps/investor-tokenization/src/app/roi/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { useState, useMemo } from "react";
import { RoiHeader } from "@/features/roi/components/roi-header";
import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar";
import { CampaignList } from "@/features/roi/components/campaign-list";
import { mockCampaigns } from "@/features/roi/data/mock-campaigns";

export default function RoiPage() {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");

const filteredCampaigns = useMemo(() => {
let list = mockCampaigns;
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q)
);
}
if (filter !== "all") {
list = list.filter((c) => c.status.toLowerCase() === filter);
}
return list;
}, [search, filter]);

const handleClaimRoi = (campaignId: string) => {
console.log("Claim ROI:", campaignId);
};

return (
<div className="space-y-6">
<RoiHeader searchValue={search} onSearchChange={setSearch} />
<CampaignToolbar filterValue={filter} onFilterChange={setFilter} />
<CampaignList campaigns={filteredCampaigns} onClaimRoi={handleClaimRoi} />
</div>
);
}
9 changes: 8 additions & 1 deletion apps/investor-tokenization/src/components/shared/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { FloatingDock } from "@tokenization/ui/floating-dock";
import { CircleDollarSign, SquaresExclude, Wallet } from "lucide-react";
import { CircleDollarSign, SquaresExclude, TrendingUp, Wallet } from "lucide-react";
import { useEffect, useState } from "react";

export function FloatingDockDemo() {
Expand All @@ -20,6 +20,13 @@ export function FloatingDockDemo() {
),
href: "/investments",
},
{
title: "ROI",
icon: (
<TrendingUp className="h-full w-full text-neutral-500 dark:text-neutral-300" />
),
href: "/roi",
},
{
title: "Claim ROI",
icon: (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
Card,
CardContent,
CardHeader,
} from "@tokenization/ui/card";
import type { Campaign } from "../types/campaign.types";
import { CampaignStatusBadge } from "./campaign-status-badge";
import { ClaimRoiButton } from "./claim-roi-button";

type CampaignCardProps = {
campaign: Campaign;
onClaimRoi?: (campaignId: string) => void;
};

function formatMinInvest(cents: number, currency: string): string {
const value = cents / 100;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}

export function CampaignCard({ campaign, onClaimRoi }: CampaignCardProps) {
return (
<Card className="rounded-xl border bg-card shadow-sm overflow-hidden">
<CardHeader className="pb-3 px-6 pt-6 gap-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<h3 className="text-lg font-bold text-foreground leading-tight pr-2">
{campaign.title}
</h3>
<CampaignStatusBadge status={campaign.status} />
</div>
<p className="text-sm text-muted-foreground leading-snug mt-1">
{campaign.description}
</p>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0 flex flex-row items-end justify-between gap-4 flex-wrap">
<div className="flex items-baseline gap-6">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Loans completed
</p>
<p className="text-base font-bold text-foreground mt-0.5">
{campaign.loansCompleted}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Min. invest
</p>
<p className="text-base font-bold text-foreground mt-0.5">
{formatMinInvest(campaign.minInvestCents, campaign.currency)}
</p>
</div>
</div>
<ClaimRoiButton campaignId={campaign.id} onClick={onClaimRoi} />
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@tokenization/ui/select";
import { cn } from "@/lib/utils";

const filterOptions = [
{ value: "all", label: "All Campaigns" },
{ value: "ready", label: "Ready" },
{ value: "pending", label: "Pending" },
{ value: "closed", label: "Closed" },
];
Comment on lines +12 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Filter values don't match CampaignStatus type.

The filter options use lowercase values ("ready", "pending", "closed") while CampaignStatus is defined as "READY" | "PENDING" | "CLOSED". This inconsistency forces the page to use .toLowerCase() for comparison and loses type safety.

Consider aligning with the canonical type:

🔧 Proposed fix to use uppercase values
 const filterOptions = [
   { value: "all", label: "All Campaigns" },
-  { value: "ready", label: "Ready" },
-  { value: "pending", label: "Pending" },
-  { value: "closed", label: "Closed" },
+  { value: "READY", label: "Ready" },
+  { value: "PENDING", label: "Pending" },
+  { value: "CLOSED", label: "Closed" },
 ];

This allows the page to filter directly: c.status === filter without case conversion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx`
around lines 12 - 17, filterOptions uses lowercase values that don't match the
CampaignStatus union, breaking type-safety; update the values in filterOptions
(the array declared as filterOptions) so the campaign state entries use the
canonical uppercase strings "READY", "PENDING", "CLOSED" while keeping the "all"
option as-is, and then update the filter usage to compare directly via c.status
=== filter (and ensure the filter variable/type is CampaignStatus | "all") so
you no longer need .toLowerCase().


type CampaignFilterProps = {
value?: string;
onValueChange?: (value: string) => void;
className?: string;
};

export function CampaignFilter({
value = "all",
onValueChange,
className,
}: CampaignFilterProps) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
className={cn(
"w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-black",
className
)}
Comment on lines +32 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hardcoded text-black may break dark mode.

The text-black class will render black text regardless of the theme. Use text-foreground instead to respect the theme's color scheme.

🎨 Proposed fix
       <SelectTrigger
         className={cn(
-          "w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-black",
+          "w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-foreground",
           className
         )}
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<SelectTrigger
className={cn(
"w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-black",
className
)}
<SelectTrigger
className={cn(
"w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-foreground",
className
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx`
around lines 32 - 36, The SelectTrigger component's hardcoded "text-black" in
its className overrides dark mode; update the class string passed to
SelectTrigger (the cn(...) call) to replace "text-black" with "text-foreground"
so it respects theme colors, keeping the rest of the classes and the existing
cn(className) composition intact.

>
<SelectValue placeholder="All Campaigns" />
</SelectTrigger>
<SelectContent align="start">
{filterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Campaign } from "../types/campaign.types";
import { CampaignCard } from "./campaign-card";

type CampaignListProps = {
campaigns: Campaign[];
onClaimRoi?: (campaignId: string) => void;
};

export function CampaignList({ campaigns, onClaimRoi }: CampaignListProps) {
return (
<ul className="flex flex-col gap-4 list-none p-0 m-0">
{campaigns.map((campaign) => (
<li key={campaign.id}>
<CampaignCard campaign={campaign} onClaimRoi={onClaimRoi} />
</li>
))}
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Input } from "@tokenization/ui/input";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";

type CampaignSearchProps = {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
};

export function CampaignSearch({
value,
onChange,
placeholder = "Search campaigns...",
className,
}: CampaignSearchProps) {
return (
<div className={cn("relative flex-1 max-w-sm", className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
className="pl-9 h-9 rounded-lg border-input bg-background text-sm"
aria-label="Search campaigns"
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Badge } from "@tokenization/ui/badge";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

type CampaignStatusBadgeProps = {
status: string;
className?: string;
};

export function CampaignStatusBadge({ status, className }: CampaignStatusBadgeProps) {
return (
<Badge
className={cn(
"rounded-md bg-emerald-500 text-white border-0 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide gap-1 [&_svg]:size-3",
className
)}
>
<Check className="size-3" />
{status}
</Badge>
);
Comment on lines +5 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Badge doesn't differentiate between campaign statuses.

The badge always renders as green with a check icon regardless of whether the status is "READY", "PENDING", or "CLOSED". This creates a misleading UX where pending/closed campaigns appear as ready.

Additionally, status is typed as string instead of CampaignStatus, losing type safety.

🔧 Proposed fix with status-aware styling
+import type { CampaignStatus } from "../types/campaign.types";
+import { Clock, Check, XCircle } from "lucide-react";
+
+const statusConfig: Record<CampaignStatus, { icon: typeof Check; className: string }> = {
+  READY: { icon: Check, className: "bg-emerald-500 text-white" },
+  PENDING: { icon: Clock, className: "bg-yellow-500 text-white" },
+  CLOSED: { icon: XCircle, className: "bg-gray-500 text-white" },
+};
+
 type CampaignStatusBadgeProps = {
-  status: string;
+  status: CampaignStatus;
   className?: string;
 };
 
 export function CampaignStatusBadge({ status, className }: CampaignStatusBadgeProps) {
+  const config = statusConfig[status];
+  const Icon = config.icon;
+
   return (
     <Badge
       className={cn(
-        "rounded-md bg-emerald-500 text-white border-0 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide gap-1 [&_svg]:size-3",
+        "rounded-md border-0 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide gap-1 [&_svg]:size-3",
+        config.className,
         className
       )}
     >
-      <Check className="size-3" />
+      <Icon className="size-3" />
       {status}
     </Badge>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/investor-tokenization/src/features/roi/components/campaign-status-badge.tsx`
around lines 5 - 21, The badge always renders a green check for every state and
accepts a plain string; update CampaignStatusBadge to be status-aware by
defining a CampaignStatus union type (e.g., "READY" | "PENDING" | "CLOSED") and
change CampaignStatusBadgeProps.status to that type, then compute the badge
appearance inside CampaignStatusBadge (use a switch or a mapping keyed by
status) to return appropriate class names and icon components (e.g., green Check
for READY, yellow Clock/Spinner for PENDING, gray X/Lock for CLOSED) and merge
those classes with the existing cn call and className prop; ensure you provide a
safe default/fallback for unexpected values and update any imports/usages of
CampaignStatusBadge accordingly.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CampaignFilter } from "./campaign-filter";

type CampaignToolbarProps = {
filterValue?: string;
onFilterChange?: (value: string) => void;
};

export function CampaignToolbar({
filterValue = "all",
onFilterChange,
}: CampaignToolbarProps) {
return (
<div className="flex items-center gap-2">
<CampaignFilter value={filterValue} onValueChange={onFilterChange} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { Button } from "@tokenization/ui/button";
import { FileText } from "lucide-react";
import { cn } from "@/lib/utils";

type ClaimRoiButtonProps = {
campaignId: string;
className?: string;
onClick?: (campaignId: string) => void;
};

export function ClaimRoiButton({
campaignId,
className,
onClick,
}: ClaimRoiButtonProps) {
return (
<Button
type="button"
className={cn(
"bg-cyan-600 hover:bg-cyan-700 text-white font-semibold uppercase tracking-wide text-xs rounded-md h-9 px-4 gap-2 shrink-0",
className
)}
onClick={() => onClick?.(campaignId)}
>
<FileText className="size-4" />
Claim ROI
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 (
<div
className={cn(
"flex items-center gap-3 rounded-lg border border-sidebar-border bg-sidebar p-3",
className
)}
>
<Avatar className="size-10 rounded-full shrink-0">
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={name} />
) : null}
<AvatarFallback className="bg-muted text-muted-foreground text-sm font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-sidebar-foreground truncate">{name}</p>
<p className="text-xs text-muted-foreground truncate">{label}</p>
</div>
</div>
);
}
Loading
Loading