Skip to content

Commit 3e2da96

Browse files
authored
Merge pull request #44 from Escelit/feat/asset-code-filter
feat: add asset code filter for campaign discovery
2 parents faed820 + a8058bc commit 3e2da96

4 files changed

Lines changed: 151 additions & 104 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface AssetFilterDropdownProps {
2+
options: string[]; // sorted distinct asset codes
3+
value: string; // "" = "All assets"
4+
onChange: (value: string) => void;
5+
disabled?: boolean;
6+
}
7+
8+
export function AssetFilterDropdown({
9+
options,
10+
value,
11+
onChange,
12+
disabled = false,
13+
}: AssetFilterDropdownProps) {
14+
return (
15+
<select
16+
value={value}
17+
onChange={(e) => onChange(e.target.value)}
18+
disabled={disabled}
19+
aria-label="Filter by asset"
20+
style={{
21+
padding: "8px 12px",
22+
border: "1px solid #cbd5e1",
23+
borderRadius: "12px",
24+
background: "#ffffff",
25+
font: "inherit",
26+
fontSize: "0.9rem",
27+
color: "#14213d",
28+
cursor: disabled ? "not-allowed" : "pointer",
29+
opacity: disabled ? 0.55 : 1,
30+
}}
31+
>
32+
<option value="">All assets</option>
33+
{options.map((code) => (
34+
<option key={code} value={code}>
35+
{code}
36+
</option>
37+
))}
38+
</select>
39+
);
40+
}
Lines changed: 77 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { LayoutGrid } from "lucide-react";
2-
import { Campaign } from "../types/campaign";
3-
import { EmptyState } from "./EmptyState";
1+
42

53
interface CampaignsTableProps {
64
campaigns: Campaign[];
@@ -19,14 +17,47 @@ export function CampaignsTable({
1917
onSelect,
2018
isLoading,
2119
}: CampaignsTableProps) {
22-
if (isLoading) {
20+
21+
22+
if (campaigns.length === 0) {
2323
return (
24-
<section className="card">
25-
<div className="section-heading">
26-
<h2>Campaign board</h2>
27-
<p className="muted">Loading campaigns...</p>
28-
</div>
24+
<EmptyState
25+
variant="card"
26+
icon={LayoutGrid}
27+
title="Campaign board"
28+
message="No campaigns yet. Create the first vault to make this board active."
29+
/>
30+
);
31+
}
32+
33+
return (
34+
<section className="card">
35+
<div className="section-heading">
36+
<h2>Campaign board</h2>
37+
{isEmpty ? (
38+
<p className="muted">
39+
No campaigns yet. Create the first vault to make this board active.
40+
</p>
41+
) : (
42+
<p className="muted">
43+
Monitor progress and open one campaign at a time in the action
44+
panel.
45+
</p>
46+
)}
47+
</div>
48+
49+
<div className="board-controls">
50+
<AssetFilterDropdown
51+
options={distinctAssetCodes}
52+
value={selectedAssetCode}
53+
onChange={setSelectedAssetCode}
54+
disabled={isEmpty}
55+
/>
56+
</div>
2957

58+
{!isEmpty && filteredCampaigns.length === 0 ? (
59+
<p className="muted">No campaigns match the current filters.</p>
60+
) : (
3061
<div className="table-wrap">
3162
<table>
3263
<thead>
@@ -40,116 +71,61 @@ export function CampaignsTable({
4071
</tr>
4172
</thead>
4273
<tbody>
43-
{Array.from({ length: 4 }).map((_, idx) => (
44-
<tr key={idx}>
74+
{filteredCampaigns.map((campaign) => (
75+
<tr key={campaign.id}>
4576
<td>
4677
<div className="stacked">
47-
<div className="skeleton skeleton-line" style={{ width: 160 }} />
48-
<div className="skeleton skeleton-line" style={{ width: 80, height: 12 }} />
78+
<strong>{campaign.title}</strong>
79+
<span className="muted">#{campaign.id}</span>
4980
</div>
5081
</td>
82+
<td className="mono">{campaign.creator.slice(0, 8)}...</td>
5183
<td>
52-
<div className="skeleton skeleton-line" style={{ width: 100 }} />
53-
</td>
54-
<td>
55-
<div className="skeleton skeleton-line" style={{ width: 140 }} />
84+
<div className="progress-copy">
85+
{campaign.pledgedAmount} / {campaign.targetAmount}{" "}
86+
{campaign.assetCode}
87+
</div>
5688
<div className="progress-bar" aria-hidden>
57-
<div style={{ width: `20%` }} />
89+
<div
90+
style={{
91+
width: `${Math.min(campaign.progress.percentFunded, 100)}%`,
92+
}}
93+
/>
5894
</div>
95+
<span className="muted">
96+
{campaign.progress.percentFunded}% funded
97+
</span>
5998
</td>
6099
<td>
61-
<div className="skeleton skeleton-line" style={{ width: 80 }} />
100+
<span className={`badge badge-${campaign.progress.status}`}>
101+
{campaign.progress.status}
102+
</span>
62103
</td>
63-
<td>
64-
<div className="skeleton skeleton-line" style={{ width: 120 }} />
65-
<div className="skeleton skeleton-line" style={{ width: 80, height: 12 }} />
104+
<td className="stacked">
105+
<span>{formatTimestamp(campaign.deadline)}</span>
106+
<span className="muted">
107+
{campaign.progress.hoursLeft}h left
108+
</span>
66109
</td>
67110
<td>
68-
<div className="skeleton skeleton-line" style={{ width: 64, height: 36 }} />
111+
<button
112+
className={
113+
selectedCampaignId === campaign.id
114+
? "btn-secondary"
115+
: "btn-ghost"
116+
}
117+
type="button"
118+
onClick={() => onSelect(campaign.id)}
119+
>
120+
{selectedCampaignId === campaign.id ? "Selected" : "View"}
121+
</button>
69122
</td>
70123
</tr>
71124
))}
72125
</tbody>
73126
</table>
74127
</div>
75-
</section>
76-
);
77-
}
78-
79-
if (campaigns.length === 0) {
80-
return (
81-
<EmptyState
82-
variant="card"
83-
icon={LayoutGrid}
84-
title="Campaign board"
85-
message="No campaigns yet. Create the first vault to make this board active."
86-
/>
87-
);
88-
}
89-
90-
return (
91-
<section className="card">
92-
<div className="section-heading">
93-
<h2>Campaign board</h2>
94-
<p className="muted">Monitor progress and open one campaign at a time in the action panel.</p>
95-
</div>
96-
97-
<div className="table-wrap">
98-
<table>
99-
<thead>
100-
<tr>
101-
<th>Campaign</th>
102-
<th>Creator</th>
103-
<th>Funding</th>
104-
<th>Status</th>
105-
<th>Deadline</th>
106-
<th />
107-
</tr>
108-
</thead>
109-
<tbody>
110-
{campaigns.map((campaign) => (
111-
<tr key={campaign.id}>
112-
<td>
113-
<div className="stacked">
114-
<strong>{campaign.title}</strong>
115-
<span className="muted">#{campaign.id}</span>
116-
</div>
117-
</td>
118-
<td className="mono">{campaign.creator.slice(0, 8)}...</td>
119-
<td>
120-
<div className="progress-copy">
121-
{campaign.pledgedAmount} / {campaign.targetAmount} {campaign.assetCode}
122-
</div>
123-
<div className="progress-bar" aria-hidden>
124-
<div style={{ width: `${Math.min(campaign.progress.percentFunded, 100)}%` }} />
125-
</div>
126-
<span className="muted">{campaign.progress.percentFunded}% funded</span>
127-
</td>
128-
<td>
129-
<span className={`badge badge-${campaign.progress.status}`}>
130-
{campaign.progress.status}
131-
</span>
132-
</td>
133-
<td className="stacked">
134-
<span>{formatTimestamp(campaign.deadline)}</span>
135-
<span className="muted">{campaign.progress.hoursLeft}h left</span>
136-
</td>
137-
<td>
138-
<button
139-
className={
140-
selectedCampaignId === campaign.id ? "btn-secondary" : "btn-ghost"
141-
}
142-
type="button"
143-
onClick={() => onSelect(campaign.id)}
144-
>
145-
{selectedCampaignId === campaign.id ? "Selected" : "View"}
146-
</button>
147-
</td>
148-
</tr>
149-
))}
150-
</tbody>
151-
</table>
152-
</div>
128+
)}
153129
</section>
154130
);
155131
}

frontend/src/components/CreateCampaignForm.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ const INITIAL_VALUES = {
1717
externalLink: "",
1818
};
1919

20-
export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormProps) {
20+
export function CreateCampaignForm({
21+
onCreate,
22+
apiError,
23+
}: CreateCampaignFormProps) {
2124
const [values, setValues] = useState(INITIAL_VALUES);
2225
const [isSubmitting, setIsSubmitting] = useState(false);
2326
const [allowedAssets, setAllowedAssets] = useState<string[]>([]);
@@ -45,7 +48,8 @@ export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormPro
4548
setIsSubmitting(true);
4649

4750
try {
48-
const deadline = Math.floor(Date.now() / 1000) + Number(values.deadlineHours) * 3600;
51+
const deadline =
52+
Math.floor(Date.now() / 1000) + Number(values.deadlineHours) * 3600;
4953
await onCreate({
5054
creator: values.creator.trim(),
5155
title: values.title.trim(),
@@ -69,7 +73,10 @@ export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormPro
6973
<section className="card">
7074
<div className="section-heading">
7175
<h2>Create Campaign</h2>
72-
<p className="muted">Spin up a Stellar goal vault for contributors and prototype the funding lifecycle.</p>
76+
<p className="muted">
77+
Spin up a Stellar goal vault for contributors and prototype the
78+
funding lifecycle.
79+
</p>
7380
</div>
7481

7582
<form className="form-grid" onSubmit={handleSubmit}>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Campaign } from "../types/campaign";
2+
3+
/**
4+
* Returns sorted, deduplicated assetCode values from the given campaigns.
5+
*/
6+
export function getDistinctAssetCodes(campaigns: Campaign[]): string[] {
7+
return [...new Set(campaigns.map((c) => c.assetCode))].sort();
8+
}
9+
10+
/**
11+
* Pure function that applies both asset code and status predicates to a campaign list.
12+
* Pass "" as assetCode or status to skip that filter.
13+
*/
14+
export function applyFilters(
15+
campaigns: Campaign[],
16+
assetCode: string,
17+
status: string,
18+
): Campaign[] {
19+
return campaigns.filter((c) => {
20+
const matchesAsset = assetCode === "" || c.assetCode === assetCode;
21+
const matchesStatus = status === "" || c.progress.status === status;
22+
return matchesAsset && matchesStatus;
23+
});
24+
}

0 commit comments

Comments
 (0)