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
2 changes: 1 addition & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"test": "vitest"
},
"dependencies": {
"axios": "^1.14.0",

"better-sqlite3": "^12.6.2",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type CampaignListItem =
? ReturnType<typeof listCampaigns>[number] & { progress: Progress }
: never;

initCampaignStore();
<

app.use(
cors({
Expand Down
88 changes: 17 additions & 71 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,7 @@ function setCampaignIdInUrl(campaignId: string | null): void {
window.history.replaceState(null, "", url.toString());
}

function toApiError(error: unknown): ApiError {
if (error instanceof Error) {
const withMetadata = error as Error & {
code?: string;
details?: Array<{ field: string; message: string }>;
requestId?: string;
};

return {
message: withMetadata.message,
code: withMetadata.code,
details: withMetadata.details,
requestId: withMetadata.requestId,
};
}

return { message: "Unexpected error" };
}

function toOptimisticPledgedCampaign(campaign: Campaign, amount: number): Campaign {
Expand Down Expand Up @@ -106,11 +90,7 @@ function App() {
const [isIssuesLoading, setIsIssuesLoading] = useState(false);
const [isSelectedLoading, setIsSelectedLoading] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(
null,
);
const [selectedCampaignDetails, setSelectedCampaignDetails] =
useState<Campaign | null>(null);

const [createError, setCreateError] = useState<ApiError | null>(null);
const [actionError, setActionError] = useState<ApiError | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -146,6 +126,11 @@ function App() {
}

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;
Expand All @@ -162,8 +147,7 @@ function App() {
return;
}

const data = await getCampaignHistory(campaignId);
setHistory([...data].reverse());

}

async function refreshSelectedCampaign(campaignId: string | null) {
Expand All @@ -177,6 +161,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;
Expand All @@ -191,22 +180,7 @@ function App() {
// made everything below it fall outside the component's scope.
useEffect(() => {
async function bootstrap() {
setInitialLoad(true);
setActionError(null);

const urlCampaignId = getCampaignIdFromUrl();

try {
setIsIssuesLoading(true);
const [fetchedIssues] = await Promise.all([
listOpenIssues(),
refreshCampaigns(urlCampaignId),
]);
setIssues(fetchedIssues);
} catch (error) {
setActionError(toApiError(error));
} finally {
setIsIssuesLoading(false);

setInitialLoad(false);
}
}
Expand All @@ -219,9 +193,6 @@ function App() {
}, []);

useEffect(() => {
if (initialLoad) {
return;
}

setSelectedCampaignDetails(null);
void Promise.all([
Expand Down Expand Up @@ -278,7 +249,7 @@ function App() {
`Campaign #${campaign.id} is live and ready for pledges.`,
);
} catch (error) {
setCreateError(toApiError(error));

}
}

Expand Down Expand Up @@ -394,7 +365,7 @@ function App() {
setHistory(previousHistory);
}

setPendingPledgeCampaignId(null);

setActionMessage(null);
setActionError(toApiError(error));
}
Expand Down Expand Up @@ -451,7 +422,7 @@ function App() {
]);
setActionMessage("Campaign claimed successfully.");
} catch (error) {
setActionError(toApiError(error));

}
}

Expand All @@ -470,12 +441,6 @@ function App() {
refreshSelectedCampaign(campaignId),
]);

setActionMessage(
`Refund confirmed on Soroban and reconciled locally (${sorobanReceipt.txHash.slice(0, 12)}...).`,
);
} catch (error) {
setActionMessage(null);
setActionError(toApiError(error));
}
}

Expand All @@ -484,6 +449,7 @@ function App() {
setSelectedCampaignId(campaignId);
}


return (
<div className="app-shell">
<header className="hero">
Expand All @@ -494,13 +460,7 @@ function App() {
</p>
</header>

{invalidUrlCampaignId ? (
<div className="form-error" style={{ marginBottom: 16 }}>
<p>Campaign #{invalidUrlCampaignId} was not found. Showing the next available campaign instead.</p>
</div>
) : null}

<section className="metrics-grid animate-fade-in">
<article className="metric-card">
<span>Total campaigns</span>
<strong>{metrics.total}</strong>
Expand Down Expand Up @@ -544,21 +504,7 @@ function App() {
/>
</section>

<section className="layout-grid animate-fade-in" style={{ animationDelay: "0.3s" }}>
<CampaignsTable
campaigns={campaigns}
selectedCampaignId={selectedCampaignId}
onSelect={handleSelect}
isLoading={isCampaignsLoading}
/>
<CampaignTimeline
history={history}
isLoading={(isSelectedLoading && !!selectedCampaignId) || initialLoad}
/>
</section>

<section className="animate-fade-in" style={{ animationDelay: "0.4s" }}>
<IssueBacklog issues={issues} isLoading={isIssuesLoading} />
</section>
</div>
);
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/CampaignDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { FormEvent, useEffect, useState } from "react";
import { ContributorSummary } from "./ContributorSummary";
import { EmptyState } from "./EmptyState";


interface CampaignDetailPanelProps {
campaign: Campaign | null;
isLoading?: boolean;
actionError?: ApiError | string | null;

actionMessage?: string | null;
isPledgePending?: boolean;
onConnectWallet: () => Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CampaignTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function CampaignTimeline({ history, isLoading }: CampaignTimelineProps)
const isPending = event.metadata?.pending === true;
const metadataLines = getMetadataLines(event);

return (

<article key={event.id} className={`timeline-item ${isPending ? "pending" : ""}`}>
<div className="timeline-dot" aria-hidden />
<div className="timeline-copy">
Expand Down
48 changes: 10 additions & 38 deletions frontend/src/components/CampaignsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import { SearchInput } from "./SearchInput";
import { applyFilters, getDistinctAssetCodes, sortCampaigns } from "./campaignsTableUtils";
import { useDebounce } from "../hooks/useDebounce";

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;
invalidUrlCampaignId?: string | null;

}

function formatTimestamp(unixSeconds: number): string {
Expand All @@ -26,23 +33,7 @@ export function CampaignsTable({
campaigns,
selectedCampaignId,
onSelect,
invalidUrlCampaignId,
isLoading = false,
}: CampaignsTableProps) {
const [selectedAssetCode, setSelectedAssetCode] = useState("");
const [searchInput, setSearchInput] = useState("");
const [sortBy, setSortBy] = useState<SortOption>("newest");
const debouncedSearchQuery = useDebounce(searchInput, 300);
const distinctAssetCodes = useMemo(() => getDistinctAssetCodes(campaigns), [campaigns]);
const filteredCampaigns = useMemo(
() => applyFilters(campaigns, selectedAssetCode, "", debouncedSearchQuery),
[campaigns, selectedAssetCode, debouncedSearchQuery],
);
const sortedCampaigns = useMemo(
() => sortCampaigns(filteredCampaigns, sortBy),
[filteredCampaigns, sortBy],
);
const isEmpty = campaigns.length === 0;


if (isLoading && isEmpty) {
return (
Expand Down Expand Up @@ -83,26 +74,7 @@ export function CampaignsTable({
)}

<div className="board-controls">
<SearchInput
value={searchInput}
onChange={setSearchInput}
disabled={campaigns.length === 0}
placeholder="Search by title, creator, or ID..."
/>
<AssetFilterDropdown
options={distinctAssetCodes}
value={selectedAssetCode}
onChange={setSelectedAssetCode}
disabled={false}
/>
<SortDropdown
value={sortBy}
onChange={setSortBy}
disabled={campaigns.length === 0}
/>
</div>

{sortedCampaigns.length === 0 ? (
<p className="muted">No campaigns match the current filters.</p>
) : (
<div className="table-wrap">
Expand All @@ -118,7 +90,7 @@ export function CampaignsTable({
</tr>
</thead>
<tbody>
{sortedCampaigns.map((campaign) => (

<tr key={campaign.id}>
<td>
<div className="stacked">
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/components/CreateCampaignForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,13 @@ export function CreateCampaignForm({
}: CreateCampaignFormProps) {
const [values, setValues] = useState(INITIAL_VALUES);
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationErrors, setValidationErrors] = useState<FormErrors>({});

useEffect(() => {
getAppConfig()
.then((appConfig) => {
if (appConfig.allowedAssets.length > 0) {
setAllowedAssets(appConfig.allowedAssets);
update("assetCode", appConfig.allowedAssets[0]);
}
})
.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) {
Expand Down Expand Up @@ -231,6 +227,12 @@ export function CreateCampaignForm({
</label>
</div>

{configError && (
<div className="form-error">
<p>⚠️ Asset Configuration: {configError}</p>
</div>
)}

{apiError ? (
<div className="form-error">
<p>{apiError.message}</p>
Expand Down
18 changes: 1 addition & 17 deletions frontend/src/components/IssueBacklog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,7 @@ export function IssueBacklog({ issues, isLoading }: IssueBacklogProps) {
</div>

<div className="issue-list">
{issues.map((issue) => (
<article key={issue.id} className="issue-item">
<div className="issue-topline">
<strong>{issue.title}</strong>
<span className="badge badge-neutral">{issue.points} pts</span>
</div>
<p>{issue.summary}</p>
<div className="chip-row">
{issue.labels.map((label) => (
<span key={label} className="chip">
{label}
</span>
))}
<span className="chip-emphasis">{issue.complexity}</span>
</div>
</article>
))}

</div>
</section>
);
Expand Down
Loading