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
40 changes: 40 additions & 0 deletions frontend/src/components/AssetFilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export interface AssetFilterDropdownProps {
options: string[]; // sorted distinct asset codes
value: string; // "" = "All assets"
onChange: (value: string) => void;
disabled?: boolean;
}

export function AssetFilterDropdown({
options,
value,
onChange,
disabled = false,
}: AssetFilterDropdownProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label="Filter by asset"
style={{
padding: "8px 12px",
border: "1px solid #cbd5e1",
borderRadius: "12px",
background: "#ffffff",
font: "inherit",
fontSize: "0.9rem",
color: "#14213d",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
}}
>
<option value="">All assets</option>
{options.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
);
}
178 changes: 77 additions & 101 deletions frontend/src/components/CampaignsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { LayoutGrid } from "lucide-react";
import { Campaign } from "../types/campaign";
import { EmptyState } from "./EmptyState";


interface CampaignsTableProps {
campaigns: Campaign[];
Expand All @@ -19,14 +17,47 @@ export function CampaignsTable({
onSelect,
isLoading,
}: CampaignsTableProps) {
if (isLoading) {


if (campaigns.length === 0) {
return (
<section className="card">
<div className="section-heading">
<h2>Campaign board</h2>
<p className="muted">Loading campaigns...</p>
</div>
<EmptyState
variant="card"
icon={LayoutGrid}
title="Campaign board"
message="No campaigns yet. Create the first vault to make this board active."
/>
);
}

return (
<section className="card">
<div className="section-heading">
<h2>Campaign board</h2>
{isEmpty ? (
<p className="muted">
No campaigns yet. Create the first vault to make this board active.
</p>
) : (
<p className="muted">
Monitor progress and open one campaign at a time in the action
panel.
</p>
)}
</div>

<div className="board-controls">
<AssetFilterDropdown
options={distinctAssetCodes}
value={selectedAssetCode}
onChange={setSelectedAssetCode}
disabled={isEmpty}
/>
</div>

{!isEmpty && filteredCampaigns.length === 0 ? (
<p className="muted">No campaigns match the current filters.</p>
) : (
<div className="table-wrap">
<table>
<thead>
Expand All @@ -40,116 +71,61 @@ export function CampaignsTable({
</tr>
</thead>
<tbody>
{Array.from({ length: 4 }).map((_, idx) => (
<tr key={idx}>
{filteredCampaigns.map((campaign) => (
<tr key={campaign.id}>
<td>
<div className="stacked">
<div className="skeleton skeleton-line" style={{ width: 160 }} />
<div className="skeleton skeleton-line" style={{ width: 80, height: 12 }} />
<strong>{campaign.title}</strong>
<span className="muted">#{campaign.id}</span>
</div>
</td>
<td className="mono">{campaign.creator.slice(0, 8)}...</td>
<td>
<div className="skeleton skeleton-line" style={{ width: 100 }} />
</td>
<td>
<div className="skeleton skeleton-line" style={{ width: 140 }} />
<div className="progress-copy">
{campaign.pledgedAmount} / {campaign.targetAmount}{" "}
{campaign.assetCode}
</div>
<div className="progress-bar" aria-hidden>
<div style={{ width: `20%` }} />
<div
style={{
width: `${Math.min(campaign.progress.percentFunded, 100)}%`,
}}
/>
</div>
<span className="muted">
{campaign.progress.percentFunded}% funded
</span>
</td>
<td>
<div className="skeleton skeleton-line" style={{ width: 80 }} />
<span className={`badge badge-${campaign.progress.status}`}>
{campaign.progress.status}
</span>
</td>
<td>
<div className="skeleton skeleton-line" style={{ width: 120 }} />
<div className="skeleton skeleton-line" style={{ width: 80, height: 12 }} />
<td className="stacked">
<span>{formatTimestamp(campaign.deadline)}</span>
<span className="muted">
{campaign.progress.hoursLeft}h left
</span>
</td>
<td>
<div className="skeleton skeleton-line" style={{ width: 64, height: 36 }} />
<button
className={
selectedCampaignId === campaign.id
? "btn-secondary"
: "btn-ghost"
}
type="button"
onClick={() => onSelect(campaign.id)}
>
{selectedCampaignId === campaign.id ? "Selected" : "View"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

if (campaigns.length === 0) {
return (
<EmptyState
variant="card"
icon={LayoutGrid}
title="Campaign board"
message="No campaigns yet. Create the first vault to make this board active."
/>
);
}

return (
<section className="card">
<div className="section-heading">
<h2>Campaign board</h2>
<p className="muted">Monitor progress and open one campaign at a time in the action panel.</p>
</div>

<div className="table-wrap">
<table>
<thead>
<tr>
<th>Campaign</th>
<th>Creator</th>
<th>Funding</th>
<th>Status</th>
<th>Deadline</th>
<th />
</tr>
</thead>
<tbody>
{campaigns.map((campaign) => (
<tr key={campaign.id}>
<td>
<div className="stacked">
<strong>{campaign.title}</strong>
<span className="muted">#{campaign.id}</span>
</div>
</td>
<td className="mono">{campaign.creator.slice(0, 8)}...</td>
<td>
<div className="progress-copy">
{campaign.pledgedAmount} / {campaign.targetAmount} {campaign.assetCode}
</div>
<div className="progress-bar" aria-hidden>
<div style={{ width: `${Math.min(campaign.progress.percentFunded, 100)}%` }} />
</div>
<span className="muted">{campaign.progress.percentFunded}% funded</span>
</td>
<td>
<span className={`badge badge-${campaign.progress.status}`}>
{campaign.progress.status}
</span>
</td>
<td className="stacked">
<span>{formatTimestamp(campaign.deadline)}</span>
<span className="muted">{campaign.progress.hoursLeft}h left</span>
</td>
<td>
<button
className={
selectedCampaignId === campaign.id ? "btn-secondary" : "btn-ghost"
}
type="button"
onClick={() => onSelect(campaign.id)}
>
{selectedCampaignId === campaign.id ? "Selected" : "View"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
13 changes: 10 additions & 3 deletions frontend/src/components/CreateCampaignForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ const INITIAL_VALUES = {
externalLink: "",
};

export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormProps) {
export function CreateCampaignForm({
onCreate,
apiError,
}: CreateCampaignFormProps) {
const [values, setValues] = useState(INITIAL_VALUES);
const [isSubmitting, setIsSubmitting] = useState(false);
const [allowedAssets, setAllowedAssets] = useState<string[]>([]);
Expand Down Expand Up @@ -45,7 +48,8 @@ export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormPro
setIsSubmitting(true);

try {
const deadline = Math.floor(Date.now() / 1000) + Number(values.deadlineHours) * 3600;
const deadline =
Math.floor(Date.now() / 1000) + Number(values.deadlineHours) * 3600;
await onCreate({
creator: values.creator.trim(),
title: values.title.trim(),
Expand All @@ -69,7 +73,10 @@ export function CreateCampaignForm({ onCreate, apiError }: CreateCampaignFormPro
<section className="card">
<div className="section-heading">
<h2>Create Campaign</h2>
<p className="muted">Spin up a Stellar goal vault for contributors and prototype the funding lifecycle.</p>
<p className="muted">
Spin up a Stellar goal vault for contributors and prototype the
funding lifecycle.
</p>
</div>

<form className="form-grid" onSubmit={handleSubmit}>
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/campaignsTableUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Campaign } from "../types/campaign";

/**
* Returns sorted, deduplicated assetCode values from the given campaigns.
*/
export function getDistinctAssetCodes(campaigns: Campaign[]): string[] {
return [...new Set(campaigns.map((c) => c.assetCode))].sort();
}

/**
* Pure function that applies both asset code and status predicates to a campaign list.
* Pass "" as assetCode or status to skip that filter.
*/
export function applyFilters(
campaigns: Campaign[],
assetCode: string,
status: string,
): Campaign[] {
return campaigns.filter((c) => {
const matchesAsset = assetCode === "" || c.assetCode === assetCode;
const matchesStatus = status === "" || c.progress.status === status;
return matchesAsset && matchesStatus;
});
}