From 0a27cdf8234355155c133d567a1dda3407bd8dfd Mon Sep 17 00:00:00 2001
From: khalifa-zoro
Date: Sat, 28 Mar 2026 03:33:09 -0700
Subject: [PATCH 1/3] #1. Added Campaign Status Filters To The Dashboard
---
backend/package-lock.json | 114 ++++++++++++++++++
backend/package.json | 1 +
backend/src/validation/schemas.ts | 1 -
frontend/src/App.tsx | 67 +++++++++-
.../src/components/CampaignDetailPanel.tsx | 6 +-
frontend/src/components/CampaignTimeline.tsx | 19 +++
frontend/src/components/CampaignsTable.tsx | 63 ++++++++--
frontend/src/components/IssueBacklog.tsx | 15 ++-
frontend/src/index.css | 6 -
9 files changed, 271 insertions(+), 21 deletions(-)
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 51098a2..ad9364e 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -8,6 +8,7 @@
"name": "stellar-goal-vault-backend",
"version": "1.0.0",
"dependencies": {
+ "axios": "^1.6.2",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
@@ -1156,6 +1157,23 @@
"node": "*"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
+ "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1420,6 +1438,18 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1555,6 +1585,15 @@
"node": ">=4.0.0"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1693,6 +1732,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1873,6 +1927,42 @@
"node": ">= 0.8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2053,6 +2143,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2802,6 +2907,15 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
diff --git a/backend/package.json b/backend/package.json
index 6844295..e649287 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -10,6 +10,7 @@
"test": "vitest"
},
"dependencies": {
+ "axios": "^1.6.2",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
diff --git a/backend/src/validation/schemas.ts b/backend/src/validation/schemas.ts
index e742ea1..fb81bb8 100644
--- a/backend/src/validation/schemas.ts
+++ b/backend/src/validation/schemas.ts
@@ -1,6 +1,5 @@
import { z } from "zod";
import { config } from "../config";
-import { z } from "zod";
export const STELLAR_ACCOUNT_REGEX = /^G[A-Z2-7]{55}$/;
export const ASSET_CODE_REGEX = /^[A-Za-z0-9]{1,12}$/;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2ead6fb..41b4a00 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -41,6 +41,18 @@ function setCampaignIdInUrl(campaignId: string | null): void {
window.history.replaceState(null, "", url.toString());
}
+function getStatusFromUrl(): string {
+ const params = new URLSearchParams(window.location.search);
+ return params.get("status") ?? "all";
+}
+
+function setStatusInUrl(status: string) {
+ const url = new URL(window.location.href);
+ if (status && status !== "all") url.searchParams.set("status", status);
+ else url.searchParams.delete("status");
+ window.history.replaceState(null, "", url.toString());
+}
+
function toOptimisticPledgedCampaign(campaign: Campaign, amount: number): Campaign {
const nowInSeconds = Math.floor(Date.now() / 1000);
const nextPledgedAmount = round(campaign.pledgedAmount + amount);
@@ -83,6 +95,7 @@ function App() {
const [isCampaignsLoading, setIsCampaignsLoading] = useState(false);
const [isSelectedLoading, setIsSelectedLoading] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
+ const [selectedStatus, setSelectedStatus] = useState(getStatusFromUrl());
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const [selectedCampaignDetails, setSelectedCampaignDetails] = useState(null);
const [createError, setCreateError] = useState(null);
@@ -120,6 +133,17 @@ function App() {
return;
}
+ const startedAt = Date.now();
+ setIsSelectedLoading(true);
+ try {
+ const data = await getCampaignHistory(campaignId);
+ setHistory(data);
+ } finally {
+ const elapsed = Date.now() - startedAt;
+ const minMs = 200;
+ if (elapsed < minMs) await delay(minMs - elapsed);
+ setIsSelectedLoading(false);
+ }
}
async function refreshSelectedCampaign(campaignId: string | null) {
@@ -142,13 +166,35 @@ function App() {
useEffect(() => {
async function bootstrap() {
-
+ const startedAt = Date.now();
+ setIsCampaignsLoading(true);
+ try {
+ const [campaignData, issueData] = await Promise.all([
+ listCampaigns(),
+ listOpenIssues(),
+ ]);
+
+ setCampaigns(campaignData);
+ setIssues(issueData);
+ setSelectedCampaignId(campaignData[0]?.id ?? null);
+ } finally {
+ const elapsed = Date.now() - startedAt;
+ const minMs = 350;
+ if (elapsed < minMs) await delay(minMs - elapsed);
+ setIsCampaignsLoading(false);
+ setInitialLoad(false);
}
}
void bootstrap();
}, []);
+ useEffect(() => {
+ // keep URL in sync when user changes filter
+ setStatusInUrl(selectedStatus);
+ }, [selectedStatus]);
+
+
useEffect(() => {
setSelectedCampaignDetails(null);
void Promise.all([
@@ -312,6 +358,10 @@ function App() {
setSelectedCampaignId(campaignId);
}
+ function handleStatusChange(status: string) {
+ setSelectedStatus(status);
+ }
+
// ── render ───────────────────────────────────────────────────────────────
return (
@@ -325,7 +375,7 @@ function App() {
-
+
Total campaigns
{metrics.total}
@@ -358,7 +408,18 @@ function App() {
/>
-
+
+
+
);
diff --git a/frontend/src/components/CampaignDetailPanel.tsx b/frontend/src/components/CampaignDetailPanel.tsx
index 9a01e46..276c090 100644
--- a/frontend/src/components/CampaignDetailPanel.tsx
+++ b/frontend/src/components/CampaignDetailPanel.tsx
@@ -1,10 +1,14 @@
import { FormEvent, useEffect, useState } from "react";
+import { MousePointer2 } from "lucide-react";
import { ContributorSummary } from "./ContributorSummary";
+import { EmptyState } from "./EmptyState";
+import { Campaign, ApiError } from "../types/campaign";
interface CampaignDetailPanelProps {
campaign: Campaign | null;
-
+ isLoading?: boolean;
+ actionError?: ApiError | null;
actionMessage?: string | null;
isPledgePending?: boolean;
onPledge: (campaignId: string, contributor: string, amount: number) => Promise;
diff --git a/frontend/src/components/CampaignTimeline.tsx b/frontend/src/components/CampaignTimeline.tsx
index 6c723e1..14c09ab 100644
--- a/frontend/src/components/CampaignTimeline.tsx
+++ b/frontend/src/components/CampaignTimeline.tsx
@@ -35,6 +35,25 @@ export function CampaignTimeline({ history, isLoading }: CampaignTimelineProps)
+ {isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, idx) => (
+
+
+
+
+ ))}
+
+ ) : history.length === 0 ? (
+
) : (
{history.map((event) => {
diff --git a/frontend/src/components/CampaignsTable.tsx b/frontend/src/components/CampaignsTable.tsx
index eb5b286..bd411ab 100644
--- a/frontend/src/components/CampaignsTable.tsx
+++ b/frontend/src/components/CampaignsTable.tsx
@@ -1,10 +1,19 @@
+import { useMemo, useState } from "react";
+import { LayoutGrid } from "lucide-react";
+
+import { Campaign } from "../types/campaign";
+import { EmptyState } from "./EmptyState";
+import { AssetFilterDropdown } from "./AssetFilterDropdown";
+
interface CampaignsTableProps {
campaigns: Campaign[];
selectedCampaignId: string | null;
onSelect: (campaignId: string) => void;
isLoading?: boolean;
+ selectedStatus?: string;
+ onStatusChange?: (status: string) => void;
}
function formatTimestamp(unixSeconds: number): string {
@@ -16,7 +25,27 @@ export function CampaignsTable({
selectedCampaignId,
onSelect,
isLoading,
+ selectedStatus,
+ onStatusChange,
}: CampaignsTableProps) {
+ const [selectedAssetCode, setSelectedAssetCode] = useState("");
+
+ const distinctAssetCodes = useMemo(() => {
+ const codes = campaigns.map((c) => c.assetCode);
+ return Array.from(new Set(codes)).sort();
+ }, [campaigns]);
+
+ const filteredCampaigns = useMemo(() => {
+ if (!selectedAssetCode) return campaigns;
+ return campaigns.filter((c) => c.assetCode === selectedAssetCode);
+ }, [campaigns, selectedAssetCode]);
+
+ const isEmpty = campaigns.length === 0;
+
+ const statusFilteredCampaigns = useMemo(() => {
+ if (!selectedStatus || selectedStatus === "all") return filteredCampaigns;
+ return filteredCampaigns.filter((c) => c.progress.status === selectedStatus);
+ }, [filteredCampaigns, selectedStatus]);
if (campaigns.length === 0) {
@@ -47,15 +76,31 @@ export function CampaignsTable({
+
+
- {!isEmpty && filteredCampaigns.length === 0 ? (
+
+
+
+ {!isEmpty && statusFilteredCampaigns.length === 0 ? (
No campaigns match the current filters.
) : (
@@ -71,7 +116,7 @@ export function CampaignsTable({
- {filteredCampaigns.map((campaign) => (
+ {statusFilteredCampaigns.map((campaign) => (
|
diff --git a/frontend/src/components/IssueBacklog.tsx b/frontend/src/components/IssueBacklog.tsx
index 3a3050c..e8dfe7e 100644
--- a/frontend/src/components/IssueBacklog.tsx
+++ b/frontend/src/components/IssueBacklog.tsx
@@ -15,7 +15,20 @@ export function IssueBacklog({ issues, isLoading }: IssueBacklogProps) {
-
+ {isLoading ? (
+ Array.from({ length: 3 }).map((_, idx) => (
+
+
+
+
+
+ ))
) : (
issues.map((issue) => (
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1404272..8862e6a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -113,9 +113,6 @@ button, input, textarea, select {
overflow: hidden;
}
-
-}
-
.metric-card {
padding: 40px;
display: flex;
@@ -518,9 +515,6 @@ tbody tr:hover td {
overflow: hidden;
}
-
-}
-
.badge {
padding: 6px 14px;
font-weight: 700;
From 8835282b1ace4cf4299c4d7d01fe5011fffc0064 Mon Sep 17 00:00:00 2001
From: khalifa-zoro
Date: Sat, 28 Mar 2026 03:40:03 -0700
Subject: [PATCH 2/3] closes #1 Added Campaign Status Filters To The Dashboard
---
frontend/src/index.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 8862e6a..d5b7722 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -8,6 +8,7 @@
--border-glass: rgba(255, 255, 255, 0.1);
--border-glass-bright: rgba(255, 255, 255, 0.2);
+
--primary: #6366f1;
--primary-glow: rgba(99, 102, 241, 0.5);
--secondary: #a855f7;
From 23982f215e1d3728129cbb33955be46ead48c274 Mon Sep 17 00:00:00 2001
From: khalifa-zoro
Date: Sat, 28 Mar 2026 10:39:50 -0700
Subject: [PATCH 3/3] closes #1. Added Campaign Status Filters To The Dashboard
and fixed the conflicts within the code
---
backend/src/index.ts | 9 ++++-
frontend/src/App.tsx | 39 +++++++++++++++++--
.../src/components/CreateCampaignForm.tsx | 13 ++++++-
3 files changed, 54 insertions(+), 7 deletions(-)
diff --git a/backend/src/index.ts b/backend/src/index.ts
index e2ef56b..1c6cbe0 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -33,8 +33,13 @@ export const app = express();
const port = Number(process.env.PORT ?? 3001);
// Initialize DB and start indexer
-initCampaignStore();
-startEventIndexer();
+try {
+ initCampaignStore();
+ startEventIndexer();
+} catch (error) {
+ console.error("Failed to initialize campaign store or event indexer:", error);
+ process.exit(1);
+}
app.use(cors());
app.use(express.json());
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 41b4a00..d13667c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -119,6 +119,11 @@ function App() {
nextSelectedId ?? selectedCampaignId ?? (data.length > 0 ? data[0].id : null);
const exists = data.some((campaign) => campaign.id === candidateId);
setSelectedCampaignId(exists ? candidateId : data[0]?.id ?? null);
+ } catch (error) {
+ console.error("Failed to refresh campaigns:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to load campaigns" }
+ );
} finally {
const elapsed = Date.now() - startedAt;
const minMs = 300;
@@ -138,6 +143,9 @@ function App() {
try {
const data = await getCampaignHistory(campaignId);
setHistory(data);
+ } catch (error) {
+ console.error("Failed to load campaign history:", error);
+ setHistory([]);
} finally {
const elapsed = Date.now() - startedAt;
const minMs = 200;
@@ -156,6 +164,11 @@ function App() {
try {
const campaign = await getCampaign(campaignId);
setSelectedCampaignDetails(campaign);
+ } catch (error) {
+ console.error("Failed to load campaign details:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to load campaign details" }
+ );
} finally {
const elapsed = Date.now() - startedAt;
const minMs = 200;
@@ -177,6 +190,11 @@ function App() {
setCampaigns(campaignData);
setIssues(issueData);
setSelectedCampaignId(campaignData[0]?.id ?? null);
+ } catch (error) {
+ console.error("Failed to load initial data:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to load initial data" }
+ );
} finally {
const elapsed = Date.now() - startedAt;
const minMs = 350;
@@ -241,7 +259,10 @@ function App() {
]);
setActionMessage(`Campaign #${campaign.id} is live and ready for pledges.`);
} catch (error) {
-
+ console.error("Failed to create campaign:", error);
+ setCreateError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to create campaign" }
+ );
}
}
@@ -315,6 +336,10 @@ function App() {
if (selectedCampaignId === campaignId) setHistory(previousHistory);
setPendingPledgeCampaignId(null);
+ console.error("Failed to record pledge:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to record pledge" }
+ );
setActionMessage(null);
}
}
@@ -331,7 +356,10 @@ function App() {
]);
setActionMessage("Campaign claimed successfully.");
} catch (error) {
-
+ console.error("Failed to claim campaign:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to claim campaign" }
+ );
}
}
@@ -347,7 +375,10 @@ function App() {
]);
setActionMessage("Refund recorded for the selected contributor.");
} catch (error) {
-
+ console.error("Failed to process refund:", error);
+ setActionError(
+ error instanceof Error ? { message: error.message } : { message: "Failed to process refund" }
+ );
}
}
@@ -375,7 +406,7 @@ function App() {
-
+
Total campaigns
{metrics.total}
diff --git a/frontend/src/components/CreateCampaignForm.tsx b/frontend/src/components/CreateCampaignForm.tsx
index ab613ba..05b2e24 100644
--- a/frontend/src/components/CreateCampaignForm.tsx
+++ b/frontend/src/components/CreateCampaignForm.tsx
@@ -24,8 +24,10 @@ export function CreateCampaignForm({
const [values, setValues] = useState(INITIAL_VALUES);
const [isSubmitting, setIsSubmitting] = useState(false);
const [allowedAssets, setAllowedAssets] = useState([]);
+ const [configError, setConfigError] = useState(null);
useEffect(() => {
+ setConfigError(null);
fetch("http://localhost:3001/api/config")
.then((res) => res.json())
.then((json) => {
@@ -36,7 +38,10 @@ export function CreateCampaignForm({
}
}
})
- .catch(console.error);
+ .catch((error) => {
+ console.error("Failed to load config:", error);
+ setConfigError(error instanceof Error ? error.message : "Failed to load asset configuration");
+ });
}, []);
function update(field: keyof typeof INITIAL_VALUES, value: string) {
@@ -181,6 +186,12 @@ export function CreateCampaignForm({
+ {configError && (
+
+ ⚠️ Asset Configuration: {configError}
+
+ )}
+
{apiError ? (
|