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 ? (

{apiError.message}