-
Notifications
You must be signed in to change notification settings - Fork 12
Frontend[UI]: Layout with global components #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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>; | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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" }, | ||||||||||||||||||||||
| ]; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded The 🎨 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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, 🔧 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 |
||
| } | ||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filter values don't match
CampaignStatustype.The filter options use lowercase values (
"ready","pending","closed") whileCampaignStatusis 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 === filterwithout case conversion.🤖 Prompt for AI Agents