Skip to content

Commit cf2238d

Browse files
authored
Merge pull request #57 from emmi-lili/components/layout
Frontend[UI]: Layout with global components
2 parents 554b9c4 + 296be5a commit cf2238d

18 files changed

+681
-3
lines changed

apps/investor-tokenization/src/app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ export default function RootLayout({
3737
children: ReactNode;
3838
}>) {
3939
return (
40-
<html lang="en">
40+
<html lang="en" className="light" suppressHydrationWarning>
4141
<body
4242
className={cn(
4343
Exo2.variable,
44-
"antialiased dark",
44+
"antialiased",
4545
spaceGrotesk.className,
4646
)}
4747
>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { RoiDashboardShell } from "@/features/roi/roi-dashboard-shell";
2+
import { ReactNode } from "react";
3+
4+
export default function RoiLayout({ children }: { children: ReactNode }) {
5+
return <RoiDashboardShell>{children}</RoiDashboardShell>;
6+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import { useState, useMemo } from "react";
4+
import { RoiHeader } from "@/features/roi/components/roi-header";
5+
import { CampaignToolbar } from "@/features/roi/components/campaign-toolbar";
6+
import { CampaignList } from "@/features/roi/components/campaign-list";
7+
import { mockCampaigns } from "@/features/roi/data/mock-campaigns";
8+
9+
export default function RoiPage() {
10+
const [search, setSearch] = useState("");
11+
const [filter, setFilter] = useState("all");
12+
13+
const filteredCampaigns = useMemo(() => {
14+
let list = mockCampaigns;
15+
if (search.trim()) {
16+
const q = search.toLowerCase();
17+
list = list.filter(
18+
(c) =>
19+
c.title.toLowerCase().includes(q) ||
20+
c.description.toLowerCase().includes(q)
21+
);
22+
}
23+
if (filter !== "all") {
24+
list = list.filter((c) => c.status.toLowerCase() === filter);
25+
}
26+
return list;
27+
}, [search, filter]);
28+
29+
const handleClaimRoi = (campaignId: string) => {
30+
console.log("Claim ROI:", campaignId);
31+
};
32+
33+
return (
34+
<div className="space-y-6">
35+
<RoiHeader searchValue={search} onSearchChange={setSearch} />
36+
<CampaignToolbar filterValue={filter} onFilterChange={setFilter} />
37+
<CampaignList campaigns={filteredCampaigns} onClaimRoi={handleClaimRoi} />
38+
</div>
39+
);
40+
}

apps/investor-tokenization/src/components/shared/Navbar.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

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

77
export function FloatingDockDemo() {
@@ -20,6 +20,13 @@ export function FloatingDockDemo() {
2020
),
2121
href: "/investments",
2222
},
23+
{
24+
title: "ROI",
25+
icon: (
26+
<TrendingUp className="h-full w-full text-neutral-500 dark:text-neutral-300" />
27+
),
28+
href: "/roi",
29+
},
2330
{
2431
title: "Claim ROI",
2532
icon: (
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
Card,
3+
CardContent,
4+
CardHeader,
5+
} from "@tokenization/ui/card";
6+
import type { Campaign } from "../types/campaign.types";
7+
import { CampaignStatusBadge } from "./campaign-status-badge";
8+
import { ClaimRoiButton } from "./claim-roi-button";
9+
10+
type CampaignCardProps = {
11+
campaign: Campaign;
12+
onClaimRoi?: (campaignId: string) => void;
13+
};
14+
15+
function formatMinInvest(cents: number, currency: string): string {
16+
const value = cents / 100;
17+
return new Intl.NumberFormat("en-US", {
18+
style: "currency",
19+
currency,
20+
minimumFractionDigits: 0,
21+
maximumFractionDigits: 0,
22+
}).format(value);
23+
}
24+
25+
export function CampaignCard({ campaign, onClaimRoi }: CampaignCardProps) {
26+
return (
27+
<Card className="rounded-xl border bg-card shadow-sm overflow-hidden">
28+
<CardHeader className="pb-3 px-6 pt-6 gap-2">
29+
<div className="flex flex-wrap items-start justify-between gap-2">
30+
<h3 className="text-lg font-bold text-foreground leading-tight pr-2">
31+
{campaign.title}
32+
</h3>
33+
<CampaignStatusBadge status={campaign.status} />
34+
</div>
35+
<p className="text-sm text-muted-foreground leading-snug mt-1">
36+
{campaign.description}
37+
</p>
38+
</CardHeader>
39+
<CardContent className="px-6 pb-6 pt-0 flex flex-row items-end justify-between gap-4 flex-wrap">
40+
<div className="flex items-baseline gap-6">
41+
<div>
42+
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
43+
Loans completed
44+
</p>
45+
<p className="text-base font-bold text-foreground mt-0.5">
46+
{campaign.loansCompleted}
47+
</p>
48+
</div>
49+
<div>
50+
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
51+
Min. invest
52+
</p>
53+
<p className="text-base font-bold text-foreground mt-0.5">
54+
{formatMinInvest(campaign.minInvestCents, campaign.currency)}
55+
</p>
56+
</div>
57+
</div>
58+
<ClaimRoiButton campaignId={campaign.id} onClick={onClaimRoi} />
59+
</CardContent>
60+
</Card>
61+
);
62+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from "@tokenization/ui/select";
10+
import { cn } from "@/lib/utils";
11+
12+
const filterOptions = [
13+
{ value: "all", label: "All Campaigns" },
14+
{ value: "ready", label: "Ready" },
15+
{ value: "pending", label: "Pending" },
16+
{ value: "closed", label: "Closed" },
17+
];
18+
19+
type CampaignFilterProps = {
20+
value?: string;
21+
onValueChange?: (value: string) => void;
22+
className?: string;
23+
};
24+
25+
export function CampaignFilter({
26+
value = "all",
27+
onValueChange,
28+
className,
29+
}: CampaignFilterProps) {
30+
return (
31+
<Select value={value} onValueChange={onValueChange}>
32+
<SelectTrigger
33+
className={cn(
34+
"w-[180px] rounded-lg border border-input bg-background h-9 text-sm font-medium text-black",
35+
className
36+
)}
37+
>
38+
<SelectValue placeholder="All Campaigns" />
39+
</SelectTrigger>
40+
<SelectContent align="start">
41+
{filterOptions.map((opt) => (
42+
<SelectItem key={opt.value} value={opt.value}>
43+
{opt.label}
44+
</SelectItem>
45+
))}
46+
</SelectContent>
47+
</Select>
48+
);
49+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Campaign } from "../types/campaign.types";
2+
import { CampaignCard } from "./campaign-card";
3+
4+
type CampaignListProps = {
5+
campaigns: Campaign[];
6+
onClaimRoi?: (campaignId: string) => void;
7+
};
8+
9+
export function CampaignList({ campaigns, onClaimRoi }: CampaignListProps) {
10+
return (
11+
<ul className="flex flex-col gap-4 list-none p-0 m-0">
12+
{campaigns.map((campaign) => (
13+
<li key={campaign.id}>
14+
<CampaignCard campaign={campaign} onClaimRoi={onClaimRoi} />
15+
</li>
16+
))}
17+
</ul>
18+
);
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client";
2+
3+
import { Input } from "@tokenization/ui/input";
4+
import { Search } from "lucide-react";
5+
import { cn } from "@/lib/utils";
6+
7+
type CampaignSearchProps = {
8+
value?: string;
9+
onChange?: (value: string) => void;
10+
placeholder?: string;
11+
className?: string;
12+
};
13+
14+
export function CampaignSearch({
15+
value,
16+
onChange,
17+
placeholder = "Search campaigns...",
18+
className,
19+
}: CampaignSearchProps) {
20+
return (
21+
<div className={cn("relative flex-1 max-w-sm", className)}>
22+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
23+
<Input
24+
type="search"
25+
value={value}
26+
onChange={(e) => onChange?.(e.target.value)}
27+
placeholder={placeholder}
28+
className="pl-9 h-9 rounded-lg border-input bg-background text-sm"
29+
aria-label="Search campaigns"
30+
/>
31+
</div>
32+
);
33+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Badge } from "@tokenization/ui/badge";
2+
import { Check } from "lucide-react";
3+
import { cn } from "@/lib/utils";
4+
5+
type CampaignStatusBadgeProps = {
6+
status: string;
7+
className?: string;
8+
};
9+
10+
export function CampaignStatusBadge({ status, className }: CampaignStatusBadgeProps) {
11+
return (
12+
<Badge
13+
className={cn(
14+
"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",
15+
className
16+
)}
17+
>
18+
<Check className="size-3" />
19+
{status}
20+
</Badge>
21+
);
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { CampaignFilter } from "./campaign-filter";
2+
3+
type CampaignToolbarProps = {
4+
filterValue?: string;
5+
onFilterChange?: (value: string) => void;
6+
};
7+
8+
export function CampaignToolbar({
9+
filterValue = "all",
10+
onFilterChange,
11+
}: CampaignToolbarProps) {
12+
return (
13+
<div className="flex items-center gap-2">
14+
<CampaignFilter value={filterValue} onValueChange={onFilterChange} />
15+
</div>
16+
);
17+
}

0 commit comments

Comments
 (0)