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
1 change: 1 addition & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cors from "cors";
import "dotenv/config";
import express, { Request, Response } from "express";
import { config } from "./config";
import { z } from "zod";
import {
addPledge,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/validation/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { z } from "zod";
import { config } from "../config";
import { z } from "zod";

Expand All @@ -22,7 +23,7 @@ export const assetCodeSchema = z
.string()
.trim()
.regex(ASSET_CODE_REGEX, "Asset code must be 1-12 alphanumeric characters.")
.transform((value) => value.toUpperCase())
.transform((value: string) => value.toUpperCase())
.refine(
(code: string) => config.allowedAssets.includes(code),
{
Expand Down
31 changes: 2 additions & 29 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,25 +325,7 @@ function App() {
</p>
</header>

{/* Invalid shared-link banner */}
{invalidUrlCampaignId && (
<div className="invalid-campaign-banner" role="alert">
<strong>Campaign not found.</strong> The link you followed referenced
campaign&nbsp;<code>{invalidUrlCampaignId}</code>, which doesn't exist or
may have been removed.
{campaigns.length > 0 && " Showing the first available campaign instead."}
<button
className="btn-ghost invalid-campaign-dismiss"
type="button"
onClick={() => setInvalidUrlCampaignId(null)}
aria-label="Dismiss"
>
</button>
</div>
)}

<section className="metric-grid">

<article className="metric-card">
<span>Total campaigns</span>
<strong>{metrics.total}</strong>
Expand All @@ -362,7 +344,7 @@ function App() {
</article>
</section>

<section className="layout-grid">
<section className="layout-grid animate-fade-in" style={{ animationDelay: "0.2s" }}>
<CreateCampaignForm onCreate={handleCreate} apiError={createError} />
<CampaignDetailPanel
campaign={selectedCampaign}
Expand All @@ -376,16 +358,7 @@ function App() {
/>
</section>

<CampaignsTable
campaigns={campaigns}
isLoading={isCampaignsLoading || initialLoad}
selectedCampaignId={selectedCampaignId}
onSelect={handleSelect}
/>

<section className="secondary-grid">
<CampaignTimeline history={history} isLoading={isSelectedLoading || initialLoad} />
<IssueBacklog issues={issues} isLoading={isSelectedLoading || initialLoad} />
</section>
</div>
);
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/CampaignDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { Campaign, ApiError } from "../types/campaign";

import { ContributorSummary } from "./ContributorSummary";

interface CampaignDetailPanelProps {
Expand Down Expand Up @@ -58,7 +58,13 @@ export function CampaignDetailPanel({
}

if (!campaign) {
return <section className="card empty-state">Pick a campaign from the board to manage it.</section>;
return (
<EmptyState
variant="card"
icon={MousePointer2}
message="Pick a campaign from the board to manage it."
/>
);
}

const activeCampaign = campaign;
Expand Down
17 changes: 3 additions & 14 deletions frontend/src/components/CampaignTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { History } from "lucide-react";
import { CampaignEvent } from "../types/campaign";
import { EmptyState } from "./EmptyState";

interface CampaignTimelineProps {
history: CampaignEvent[];
Expand Down Expand Up @@ -32,20 +34,7 @@ export function CampaignTimeline({ history, isLoading }: CampaignTimelineProps)
<p className="muted">Each action is stored locally so contributors can follow campaign activity.</p>
</div>

{isLoading ? (
<div className="timeline">
{Array.from({ length: 4 }).map((_, idx) => (
<article key={idx} className="timeline-item">
<div className="timeline-dot" aria-hidden />
<div className="timeline-copy">
<div className="skeleton skeleton-line" style={{ width: 160 }} />
<div className="skeleton skeleton-line" style={{ width: 100, height: 12 }} />
</div>
</article>
))}
</div>
) : history.length === 0 ? (
<div className="empty-state">Select a campaign to see lifecycle events.</div>

) : (
<div className="timeline">
{history.map((event) => {
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/components/CampaignsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { LayoutGrid } from "lucide-react";
import { Campaign } from "../types/campaign";
import { EmptyState } from "./EmptyState";

interface CampaignsTableProps {
campaigns: Campaign[];
Expand Down Expand Up @@ -76,12 +78,12 @@ export function CampaignsTable({

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

Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { LucideIcon } from "lucide-react";

interface EmptyStateProps {
title?: string;
message: string;
icon?: LucideIcon;
variant?: "card" | "inline";
className?: string;
}

/**
* A reusable component for displaying empty states across the application.
* Standardizes the visual structure and styling for consistent UX.
*/
export function EmptyState({
title,
message,
icon: Icon,
variant = "inline",
className = "",
}: EmptyStateProps) {
const containerClass = variant === "card" ? "card empty-state-container" : "empty-state-container";

return (
<div className={`${containerClass} animate-fade-in ${className}`}>
{Icon && (
<div className="empty-state-icon">
<Icon size={48} strokeWidth={1.2} />
</div>
)}
<div className="empty-state-content">
{title && <h3 className="empty-state-title">{title}</h3>}
<p className="empty-state-message muted">{message}</p>
</div>
</div>
);
}
17 changes: 3 additions & 14 deletions frontend/src/components/IssueBacklog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ListTodo } from "lucide-react";
import { OpenIssue } from "../types/campaign";
import { EmptyState } from "./EmptyState";

interface IssueBacklogProps {
issues: OpenIssue[];
Expand All @@ -13,20 +15,7 @@ export function IssueBacklog({ issues, isLoading }: IssueBacklogProps) {
</div>

<div className="issue-list">
{isLoading ? (
Array.from({ length: 3 }).map((_, idx) => (
<article key={idx} className="issue-item">
<div className="issue-topline">
<div className="skeleton skeleton-line" style={{ width: 180 }} />
<div className="skeleton skeleton-line" style={{ width: 48, height: 20 }} />
</div>
<div className="skeleton skeleton-line" style={{ width: '100%', height: 36, marginTop: 8 }} />
<div className="chip-row" style={{ marginTop: 8 }}>
<div className="skeleton skeleton-line" style={{ width: 80, height: 28 }} />
<div className="skeleton skeleton-line" style={{ width: 80, height: 28 }} />
</div>
</article>
))

) : (
issues.map((issue) => (
<article key={issue.id} className="issue-item">
Expand Down
Loading