Dework-clone community work platform: Overview / Board / Open Tasks / Leaderboards / Suggestions / Combined Board / task-detail + Explore

Extends the on-chain work-board into a full Dework-style community surface,
white-labeled per domain, on our own primitives. Every view derives from ONE
on-chain read (loadWorkspace) so header/board/leaderboard numbers can't drift.

Views (all reading live Bounty/Reputation over JSON-RPC):
- Overview: org header + Time-to-payment / Total-paid stats (from PaymentReleased
  + BountyProposed event timestamps), Discord role-gate banner, Bounties category
  grid (Spaces), Projects, right rail (About/Contributors/Admins).
- Board: 4-lane kanban (To Do/In Progress/In Review/Done) with Dework empty-state
  framing; task cards show title + skill tags + reward + open-to badge + assignee.
- Open Tasks: rows grouped by status with skill tags + reward + assignee.
- Leaderboards: Top Contributors + Top Reviewers (7d/30d/all-time; windows from the
  event stream, all-time from Reputation) + All Contributors; global-Karma column.
- Community Suggestions: create modal + upvotes (local-first, IAM/on-chain-swappable).
- Combined Board (all Spaces) + Space tabs (Overview/Board/Open Tasks/Suggestions).
- Task detail: on-chain description + real activity log (created→funded→claimed→
  submitted→accepted→paid) + apply/comment flow + status/assignee/reviewers.
- Explore: cross-space skill-filtered index + sibling-DAO links.

Data layer:
- chain.loadWorkspace assembles bounties + activity + stats + contributors (workers
  ∪ reviewers) in one pass; block timestamps batched.
- Reward model is a native/ERC-20/ERC-721/ERC-1155 union — parseReward reads
  rewardType+tokenId when the finished Bounty struct carries them (NFT rewards
  render with tokenURI metadata), defaults to native/ERC-20 today.
- Karma optional per brand (global soul-bound rep); degrades cleanly when absent.

Config-driven white-label: Spaces/skills/tagline/accent/socials/Karma/IAM added to
the existing VITE_BRAND brand map (zoo, pars); one canonical config, one image/brand.
Hash router (no dep); Dework dark palette; per-brand accent via --brand.

package: 0.1.0 -> 0.2.0

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-17 17:22:22 -07:00
co-authored by Hanzo Dev
parent c0ddcb713d
commit 2e624f644d
28 changed files with 2655 additions and 425 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "work-board",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"description": "White-label on-chain bounty kanban (Zoo, Pars) — reads the live BountyV1/EscrowV1/ReputationV1 work-market over JSON-RPC.",
"description": "White-label on-chain community work platform (Zoo, Pars) — a Dework-style Overview / Board / Open Tasks / Leaderboards / Suggestions / task-detail surface reading the live Bounty / Escrow / Reputation / Karma work-market over JSON-RPC.",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
+77 -138
View File
@@ -1,161 +1,100 @@
import { useMemo, useState } from 'react';
import { ADDRESSES, CHAIN_ID, ORG_NAME, OWNER_LABEL, REFRESH_MS } from './config';
import { COLUMNS, State } from './types';
import { short, tokenLabel } from './format';
import { useBounties, type Source } from './useBounties';
import { Column } from './components/Column';
import { useEffect, useMemo, useState } from 'react';
import { ORG, CHAIN_ID } from './config';
import { useWorkspace } from './useWorkspace';
import type { Source } from './useWorkspace';
import { useRoute } from './router';
import { Sidebar } from './components/Sidebar';
import { Topbar, Toolbar } from './components/chrome';
import { Overview } from './components/Overview';
import { SpaceView } from './components/Space';
import { Board } from './components/Board';
import { Leaderboards } from './components/Leaderboards';
import { Suggestions } from './components/Suggestions';
import { Explore } from './components/Explore';
import { TaskDetail } from './components/TaskDetail';
import type { Task } from './types';
import type { Workspace } from './chain';
const REFRESH_S = Math.round(REFRESH_MS / 1000);
function SourceBadge({ source }: { source: Source | null }) {
function StatusPill({ source, updatedAt, error }: { source: Source | null; updatedAt: number | null; error: string | null }) {
if (source === null) return null;
const live = source === 'live';
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${live ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'}`} />
<span className={`font-medium tracking-wide ${live ? 'text-emerald-300' : 'text-amber-300'}`}>
{live ? 'LIVE' : 'FIXTURE'}
</span>
</span>
);
}
function Header() {
return (
<header className="px-5 pt-5 pb-4 border-b border-neutral-800">
<div className="flex items-baseline gap-x-3 gap-y-1 flex-wrap">
<h1 className="text-lg font-semibold tracking-tight text-neutral-100">
{ORG_NAME} <span className="text-neutral-500 font-normal">— Work Board</span>
</h1>
<span className="text-xs text-neutral-500">
chain {CHAIN_ID}
{' · '}BountyV1{' '}
<span className="font-mono text-neutral-400" title={ADDRESSES.bounty}>
{short(ADDRESSES.bounty, 6, 6)}
</span>
{' · '}owner{' '}
<span className="text-neutral-400" title={ADDRESSES.owner}>
{OWNER_LABEL}
</span>
</span>
</div>
<p className="mt-1.5 text-xs text-neutral-500 max-w-2xl">
A permissionless on-chain bounty board. Worker reputation is recorded in{' '}
<span className="text-emerald-400/90">ReputationV1</span> and travels with the address —
portable, not platform-locked.
</p>
</header>
);
}
function Banner({ source, error }: { source: Source | null; error: string | null }) {
if (!error) return null;
const fixture = source === 'fixture';
const msg = fixture
? 'Live RPC unreachable — showing the dev fixture of bounty #0 so the board stays verifiable.'
: 'Refresh failed — showing the last good on-chain data.';
return (
<div className="mx-5 mt-3 rounded-md bg-amber-500/10 ring-1 ring-inset ring-amber-500/30 px-3 py-2 text-xs text-amber-200">
<span className="font-medium">{msg}</span>
<span className="text-amber-200/60 font-mono ml-2 break-all">{error.slice(0, 160)}</span>
<div className="fixed bottom-3 left-3 z-40 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-neutral-400 ring-1 ring-inset ring-white/10 backdrop-blur">
<span className={`h-1.5 w-1.5 rounded-full ${live ? 'animate-pulse bg-emerald-400' : 'bg-amber-400'}`} />
<span className={live ? 'text-emerald-300' : 'text-amber-300'}>{live ? 'LIVE' : 'FIXTURE'}</span>
<span className="text-neutral-600">· chain {CHAIN_ID}</span>
{updatedAt && <span className="text-neutral-600">· {new Date(updatedAt).toLocaleTimeString()}</span>}
{error && !live && <span className="max-w-[16rem] truncate text-amber-400/70" title={error}>· {error.slice(0, 40)}</span>}
</div>
);
}
function Spinner() {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-neutral-500">
<span className="h-6 w-6 rounded-full border-2 border-neutral-700 border-t-emerald-400 animate-spin" />
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-neutral-500">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-neutral-700" style={{ borderTopColor: 'var(--brand)' }} />
<span className="text-sm">Reading the work-market…</span>
</div>
);
}
const selectClass =
'bg-neutral-900 ring-1 ring-inset ring-neutral-800 rounded px-2 py-1 text-neutral-200 text-xs focus:outline-none focus:ring-neutral-600';
export function App() {
const { data, loading, error, source, updatedAt } = useBounties();
const [stateFilter, setStateFilter] = useState<State | 'all'>('all');
const [tokenFilter, setTokenFilter] = useState<string>('all');
const bounties = data ?? [];
// Distinct tokens present, for the token filter.
const tokens = useMemo(() => {
const seen = new Map<string, string>();
for (const b of bounties) seen.set(b.token.toLowerCase(), tokenLabel(b.token));
return Array.from(seen.entries());
}, [bounties]);
const filtered = useMemo(
() => bounties.filter((b) => tokenFilter === 'all' || b.token.toLowerCase() === tokenFilter),
[bounties, tokenFilter],
);
const columns = COLUMNS.filter((c) => stateFilter === 'all' || c.state === stateFilter);
const showEmpty = source === 'live' && bounties.length === 0;
function CombinedBoard({ tasks }: { tasks: Task[] }) {
const [query, setQuery] = useState('');
const shown = query ? tasks.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) : tasks;
return (
<div className="min-h-full flex flex-col">
<Header />
<div className="px-5 py-2 flex items-center gap-3 text-xs border-b border-neutral-900">
<SourceBadge source={source} />
<span className="text-neutral-500 tabular-nums">{bounties.length} bounties</span>
{updatedAt && (
<span className="text-neutral-600">updated {new Date(updatedAt).toLocaleTimeString()}</span>
)}
<span className="text-neutral-600 ml-auto">auto-refresh {REFRESH_S}s</span>
<div className="px-8 py-6">
<div className="mb-5 flex items-center justify-between pr-32">
<h1 className="text-2xl font-bold text-neutral-50">Combined Board</h1>
<Toolbar query={query} onQuery={setQuery} />
</div>
<Board tasks={shown} />
</div>
);
}
<div className="px-5 py-3 flex items-center gap-4 flex-wrap border-b border-neutral-900">
<label className="flex items-center gap-2 text-xs text-neutral-500">
state
<select
className={selectClass}
value={stateFilter === 'all' ? 'all' : String(stateFilter)}
onChange={(e) => setStateFilter(e.target.value === 'all' ? 'all' : (Number(e.target.value) as State))}
>
<option value="all">all</option>
{COLUMNS.map((c) => (
<option key={c.state} value={c.state}>
{c.title}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-xs text-neutral-500">
token
<select className={selectClass} value={tokenFilter} onChange={(e) => setTokenFilter(e.target.value)}>
<option value="all">all</option>
{tokens.map(([addr, label]) => (
<option key={addr} value={addr}>
{label}
</option>
))}
</select>
</label>
</div>
function Routed({ ws, tasks }: { ws: Workspace; tasks: Task[] }) {
const route = useRoute();
switch (route.view) {
case 'overview':
return <Overview ws={ws} tasks={tasks} />;
case 'board':
return <CombinedBoard tasks={tasks} />;
case 'leaderboards':
return <Leaderboards ws={ws} />;
case 'suggestions':
return <Suggestions />;
case 'explore':
return <Explore tasks={tasks} />;
case 'space':
return <SpaceView tasks={tasks} spaceKey={route.spaceKey!} tab={route.tab} />;
default:
return <Overview ws={ws} tasks={tasks} />;
}
}
<Banner source={source} error={error} />
export function App() {
const { ws, tasks, loading, error, source, updatedAt } = useWorkspace();
const route = useRoute();
{loading && data === null ? (
<Spinner />
) : showEmpty ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-neutral-500">
<span className="text-2xl">◇</span>
<span className="text-sm">No bounties on-chain yet.</span>
</div>
) : (
<main className="flex-1 overflow-x-auto p-5">
<div className="flex gap-4 min-w-max items-start">
{columns.map((c) => (
<Column key={c.state} def={c} bounties={filtered.filter((b) => b.state === c.state)} />
))}
</div>
</main>
)}
// Inject the per-brand accent once; every accented element reads var(--brand).
useEffect(() => {
document.documentElement.style.setProperty('--brand', ORG.accent);
}, []);
const modalTask = useMemo(
() => (route.taskId !== undefined ? tasks.find((t) => t.id === route.taskId) ?? null : null),
[route.taskId, tasks],
);
return (
<div className="flex h-full">
<Sidebar />
<main className="relative flex-1 overflow-y-auto bg-[#0f0f11]">
<Topbar />
{loading && ws === null ? <Spinner /> : ws ? <Routed ws={ws} tasks={tasks} /> : null}
</main>
{modalTask && ws && <TaskDetail task={modalTask} ws={ws} />}
<StatusPill source={source} updatedAt={updatedAt} error={error} />
</div>
);
}
+120 -2
View File
@@ -1,6 +1,6 @@
// ABI fragments copied from the compiled Foundry artifacts
// contracts/out-foundry/BountyV1.sol/BountyV1.json
// contracts/out-foundry/ReputationV1.sol/ReputationV1.json
// dao/contracts/out-foundry/{Bounty,Reputation}.sol/*.json
// standard/contracts/governance/Karma.sol
// Only the members this board actually reads are included (no cross-repo import).
export const bountyAbi = [
@@ -40,6 +40,8 @@ export const bountyAbi = [
},
] as const;
// ---- Events (assemble refs, activity log, stats and reviewer counts) ----
export const bountyProposedEvent = {
type: 'event',
name: 'BountyProposed',
@@ -55,6 +57,29 @@ export const bountyProposedEvent = {
],
} as const;
export const bountyFundedEvent = {
type: 'event',
name: 'BountyFunded',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'funder', type: 'address', indexed: true },
{ name: 'amount', type: 'uint256', indexed: false },
],
} as const;
export const bountyClaimedEvent = {
type: 'event',
name: 'BountyClaimed',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'worker', type: 'address', indexed: true },
{ name: 'stake', type: 'uint256', indexed: false },
{ name: 'claimDeadline', type: 'uint64', indexed: false },
],
} as const;
export const workSubmittedEvent = {
type: 'event',
name: 'WorkSubmitted',
@@ -66,6 +91,63 @@ export const workSubmittedEvent = {
],
} as const;
export const workAcceptedEvent = {
type: 'event',
name: 'WorkAccepted',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'approver', type: 'address', indexed: true },
{ name: 'worker', type: 'address', indexed: true },
],
} as const;
export const paymentReleasedEvent = {
type: 'event',
name: 'PaymentReleased',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'worker', type: 'address', indexed: true },
{ name: 'reward', type: 'uint256', indexed: false },
{ name: 'stakeReturned', type: 'uint256', indexed: false },
],
} as const;
export const bountyCancelledEvent = {
type: 'event',
name: 'BountyCancelled',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'funder', type: 'address', indexed: true },
{ name: 'amount', type: 'uint256', indexed: false },
],
} as const;
export const bountyDisputedEvent = {
type: 'event',
name: 'BountyDisputed',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'disputer', type: 'address', indexed: true },
{ name: 'reasonRef', type: 'string', indexed: false },
],
} as const;
export const bountyFinalizedEvent = {
type: 'event',
name: 'BountyFinalized',
anonymous: false,
inputs: [
{ name: 'bountyId', type: 'uint256', indexed: true },
{ name: 'finalizer', type: 'address', indexed: true },
{ name: 'worker', type: 'address', indexed: true },
],
} as const;
// ---- Reputation (per-DAO worker ledger) ----
export const reputationAbi = [
{
type: 'function',
@@ -82,3 +164,39 @@ export const reputationAbi = [
stateMutability: 'view',
},
] as const;
// ---- Karma (global soul-bound reputation; optional per org) ----
export const karmaAbi = [
{
type: 'function',
name: 'karmaOf',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
stateMutability: 'view',
},
] as const;
// ---- NFT reward metadata (ERC-721 tokenURI / name, ERC-1155 uri) ----
export const nftMetaAbi = [
{
type: 'function',
name: 'tokenURI',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [{ name: '', type: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'uri',
inputs: [{ name: 'id', type: 'uint256' }],
outputs: [{ name: '', type: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'name',
inputs: [],
outputs: [{ name: '', type: 'string' }],
stateMutability: 'view',
},
] as const;
+66 -8
View File
@@ -2,10 +2,25 @@ import type { Address } from 'viem';
// The one place a white-label lives. A brand is selected at BUILD time by the
// VITE_BRAND build-arg (see Dockerfile / docker.yml); default is 'zoo'. Every
// brand-varying value — chain, addresses, owner label, header/title text, and
// the RPC host baked into the CSP — is a field here, so standing up a new board
// is a data change, not a code change. `zoo` reproduces the original hardcoded
// values exactly, so the default (Zoo) build is byte-for-byte unchanged.
// brand-varying value — chain, addresses, owner label, header/title text, the RPC
// host baked into the CSP, and all Dework presentation (tagline, accent, socials,
// Spaces/categories) — is a field here, so standing up a new board is a data
// change, not a code change. Never cross brands: a Zoo build shows only Zoo.
// A Space is a bounty category (Dework "Space"). One Bounty serves the whole
// org, so a bounty is routed to a Space by matching its issueRef against `match`
// prefixes (config, not on-chain). `safe` is the sub-DAO / working-group treasury
// where one exists; else the org DAO Safe owns it. `project: true` renders the
// Space under "Projects based bounties" instead of "Bounties".
export type Space = {
key: string;
name: string;
emoji: string;
skills: string[];
match?: string[];
safe?: Address;
project?: boolean;
};
export type Brand = {
chainId: number;
@@ -16,13 +31,28 @@ export type Brand = {
escrow: Address;
reputation: Address;
owner: Address;
// Global soul-bound Karma (optional — set once canonical on this chain).
karma?: Address;
// DAO governor / fractal module (admins panel; optional).
governor?: Address;
};
ownerLabel: string;
// Organisation name shown in the header and <title>; the board renders it as
// `${org} — Work Board`.
// Organisation name shown in the header and <title>; `${org} — Work Board`.
org: string;
// Canonical public RPC. Baked into the bundle at build time (also the source
// of the CSP connect-src host). Overridable per-build via VITE_RPC_URL.
// Dework workspace name (sidebar + header), e.g. "Zoo".
workspace: string;
tagline: string;
// Accent hex — primary buttons, active nav, highlights. Per-brand, never cross.
accent: string;
usdPerNative: number; // native → USD for the "Total paid: $X" header stat
social: { discord?: string; twitter?: string; website?: string };
tags: string[]; // About-panel chips
spaces: Space[];
// Hanzo IAM (OIDC) endpoints — Connect / Connect-with-Discord targets. Wired by
// the CTO agent; optional until then.
iam?: { authUrl: string; discordUrl: string };
// Canonical public RPC. Baked into the bundle at build time (also the source of
// the CSP connect-src host). Overridable per-build via VITE_RPC_URL.
rpcUrl: string;
};
@@ -36,9 +66,24 @@ export const BRANDS = {
escrow: '0x095E68282aea751Cc70A2Be565270f1B6AB0229C',
reputation: '0xed976852e8c2b1283e4F475845046B679224460D',
owner: '0x229599f227231d8C90fcF1a78589F5DC4b7A6962',
governor: '0xc23e396acA1CbB0D0cF1debc8371eDddbf52430e',
// karma: set by CTO agent once canonical on 200200 (pod-split today).
},
ownerLabel: 'Zoo DAO Safe',
org: 'Zoo DAO',
workspace: 'Zoo',
tagline: 'Open AI research network — DeAI & DeSci',
accent: '#22c55e',
usdPerNative: 1,
social: { discord: 'https://discord.gg/zoo', twitter: 'https://twitter.com/zoo_labs', website: 'https://zoo.ngo' },
tags: ['DeAI', 'DeSci', 'Research', 'Open Source', 'AI'],
spaces: [
{ key: 'engineering', name: 'Engineering', emoji: '⚙️', skills: ['Development', 'Product'], match: ['zooai/', 'zoo-'] },
{ key: 'research', name: 'AI & Research', emoji: '🧬', skills: ['Research', 'Data Analytics'], match: ['research/', 'zoo-research'] },
{ key: 'community', name: 'Community & Social', emoji: '💬', skills: ['Community', 'Marketing'] },
{ key: 'content', name: 'Content Creation', emoji: '🎬', skills: ['Writing', 'Design'] },
{ key: 'governance', name: 'Governance', emoji: '🏛️', skills: ['Legal', 'Operations'], match: ['zips/'] },
],
rpcUrl: 'https://api.zoo.network/v1/bc/C/rpc',
},
pars: {
@@ -53,8 +98,21 @@ export const BRANDS = {
},
ownerLabel: 'Pars DAO Safe',
org: 'Pars DAO',
workspace: 'Pars',
tagline: 'The community L1',
accent: '#3b82f6',
usdPerNative: 1,
social: { website: 'https://pars.network' },
tags: ['Community', 'L1', 'Governance'],
spaces: [
{ key: 'engineering', name: 'Engineering', emoji: '⚙️', skills: ['Development', 'Product'] },
{ key: 'community', name: 'Community & Social', emoji: '💬', skills: ['Community', 'Marketing'] },
{ key: 'content', name: 'Content Creation', emoji: '🎬', skills: ['Writing', 'Design'] },
],
rpcUrl: 'https://api.pars.network/v1/bc/C/rpc',
},
// lux + hanzo brands land in the batch-3 white-label pass, together with their
// fixtures, CI matrix entries and k8s manifests (post flag-day / deploying).
} as const satisfies Record<string, Brand>;
export type BrandKey = keyof typeof BRANDS;
+213 -42
View File
@@ -1,8 +1,24 @@
import { createPublicClient, defineChain, http } from 'viem';
import { bountyAbi, bountyProposedEvent, reputationAbi, workSubmittedEvent } from './abi';
import type { Address } from 'viem';
import {
bountyAbi,
bountyCancelledEvent,
bountyClaimedEvent,
bountyDisputedEvent,
bountyFinalizedEvent,
bountyFundedEvent,
bountyProposedEvent,
karmaAbi,
nftMetaAbi,
paymentReleasedEvent,
reputationAbi,
workAcceptedEvent,
workSubmittedEvent,
} from './abi';
import { ADDRESSES, CHAIN_ID, CHAIN_NAME, NATIVE_SYMBOL, RPC_URL } from './config';
import { isZero } from './format';
import type { Bounty, BountyView, Reputation } from './types';
import type { Activity, Bounty, BountyView, Contributor, OrgStats, Reputation } from './types';
import type { NftMeta } from './reward';
// Resolve a root-relative RPC ('/rpc', dev proxy) to an absolute URL for viem/fetch.
function resolveUrl(u: string): string {
@@ -23,8 +39,20 @@ const client = createPublicClient({
transport: http(rpc, { batch: true }),
});
// Read the entire work-market and assemble the board. This is the real path.
export async function loadBoard(): Promise<BountyView[]> {
// Karma is optional — only read when the org has a canonical Karma deployment.
const KARMA: Address | undefined = ADDRESSES.karma;
// The whole work-market, assembled once: board, activity log, stats, contributors.
// Every view derives from this single on-chain read, so numbers can never drift
// between the header, the board and the leaderboard.
export type Workspace = {
bounties: BountyView[];
activity: Activity[];
stats: OrgStats;
contributors: Contributor[];
};
export async function loadWorkspace(): Promise<Workspace> {
const count = (await client.readContract({
address: ADDRESSES.bounty,
abi: bountyAbi,
@@ -32,56 +60,71 @@ export async function loadBoard(): Promise<BountyView[]> {
})) as bigint;
const n = Number(count);
if (n === 0) return [];
const ids = Array.from({ length: n }, (_, i) => i);
// Current struct per bounty (batched into one JSON-RPC request).
const bounties = (await Promise.all(
const bountyStructs = (await Promise.all(
ids.map((id) =>
client.readContract({
address: ADDRESSES.bounty,
abi: bountyAbi,
functionName: 'bounties',
args: [BigInt(id)],
}),
client.readContract({ address: ADDRESSES.bounty, abi: bountyAbi, functionName: 'bounties', args: [BigInt(id)] }),
),
)) as unknown as Bounty[];
// issueRef / deliverableRef live only in events, not storage.
const [proposed, submitted] = await Promise.all([
client.getLogs({ address: ADDRESSES.bounty, event: bountyProposedEvent, fromBlock: 0n, toBlock: 'latest' }),
client.getLogs({ address: ADDRESSES.bounty, event: workSubmittedEvent, fromBlock: 0n, toBlock: 'latest' }),
// The full event stream in one shot — refs, activity, stats, reviewer counts.
// Each getLogs is inlined (not wrapped) so viem infers the per-event arg types.
const range = { address: ADDRESSES.bounty, fromBlock: 0n, toBlock: 'latest' } as const;
const [proposed, funded, claimed, submitted, accepted, paid, cancelled, disputed, finalized] = await Promise.all([
client.getLogs({ ...range, event: bountyProposedEvent }),
client.getLogs({ ...range, event: bountyFundedEvent }),
client.getLogs({ ...range, event: bountyClaimedEvent }),
client.getLogs({ ...range, event: workSubmittedEvent }),
client.getLogs({ ...range, event: workAcceptedEvent }),
client.getLogs({ ...range, event: paymentReleasedEvent }),
client.getLogs({ ...range, event: bountyCancelledEvent }),
client.getLogs({ ...range, event: bountyDisputedEvent }),
client.getLogs({ ...range, event: bountyFinalizedEvent }),
]);
const issueRefById = new Map<number, string>();
for (const log of proposed) {
const id = log.args.bountyId;
const ref = log.args.issueRef;
if (id !== undefined && ref !== undefined) issueRefById.set(Number(id), ref);
}
const deliverableById = new Map<number, string>();
for (const log of submitted) {
const id = log.args.bountyId;
const ref = log.args.deliverableRef;
if (id !== undefined && ref !== undefined) deliverableById.set(Number(id), ref);
}
// Portable on-chain reputation, one lookup per distinct non-zero worker.
const workers = Array.from(new Set(bounties.filter((b) => !isZero(b.worker)).map((b) => b.worker)));
const repEntries = await Promise.all(
workers.map(async (w) => {
const [completed, earned] = await Promise.all([
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'completedOf', args: [w] }),
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'earnedOf', args: [w] }),
]);
return [w.toLowerCase(), { completed: completed as bigint, earned: earned as bigint }] as const;
// Block timestamps for every event block (deduped, batched).
const blockNums = new Set<bigint>();
for (const group of [proposed, funded, claimed, submitted, accepted, paid, cancelled, disputed, finalized])
for (const l of group) if (l.blockNumber != null) blockNums.add(l.blockNumber);
const tsByBlock = new Map<bigint, number>();
await Promise.all(
Array.from(blockNums).map(async (bn) => {
const block = await client.getBlock({ blockNumber: bn });
tsByBlock.set(bn, Number(block.timestamp));
}),
);
const repByWorker = new Map<string, Reputation>(repEntries);
const tsOf = (bn: bigint | null | undefined): number => (bn != null ? (tsByBlock.get(bn) ?? 0) : 0);
return ids.map((id) => {
const b = bounties[id];
// Event-only refs.
const issueRefById = new Map<number, string>();
for (const l of proposed) if (l.args.issueRef != null) issueRefById.set(Number(l.args.bountyId), l.args.issueRef);
const deliverableById = new Map<number, string>();
for (const l of submitted)
if (l.args.deliverableRef != null) deliverableById.set(Number(l.args.bountyId), l.args.deliverableRef);
// Reviewer counts (WorkAccepted approver), collected first so a pure reviewer
// (someone who reviews but never works a task) still appears on the leaderboard.
const reviewedBy = new Map<string, number>();
const reviewers: Address[] = [];
for (const l of accepted) {
const a = l.args.approver;
if (a && !isZero(a)) {
reviewedBy.set(a.toLowerCase(), (reviewedBy.get(a.toLowerCase()) ?? 0) + 1);
reviewers.push(a);
}
}
// Reputation + Karma for every distinct participant (workers ∪ reviewers).
const participantsByAddr = new Map<string, Address>();
for (const b of bountyStructs) if (!isZero(b.worker)) participantsByAddr.set(b.worker.toLowerCase(), b.worker);
for (const r of reviewers) participantsByAddr.set(r.toLowerCase(), r);
const participants = Array.from(participantsByAddr.values());
const repByWorker = await reputationFor(participants);
const bounties: BountyView[] = ids.map((id) => {
const b = bountyStructs[id];
return {
id,
...b,
@@ -90,4 +133,132 @@ export async function loadBoard(): Promise<BountyView[]> {
reputation: repByWorker.get(b.worker.toLowerCase()) ?? { completed: 0n, earned: 0n },
};
});
// Activity log, newest first.
const activity: Activity[] = [];
for (const l of proposed)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'proposed', actor: l.args.funder!, ts: tsOf(l.blockNumber), detail: l.args.issueRef });
for (const l of funded)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'funded', actor: l.args.funder!, ts: tsOf(l.blockNumber) });
for (const l of claimed)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'claimed', actor: l.args.worker!, ts: tsOf(l.blockNumber) });
for (const l of submitted)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'submitted', actor: l.args.worker!, ts: tsOf(l.blockNumber), detail: l.args.deliverableRef });
for (const l of accepted)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'accepted', actor: l.args.approver!, ts: tsOf(l.blockNumber) });
for (const l of paid)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'paid', actor: l.args.worker!, ts: tsOf(l.blockNumber), detail: l.args.reward?.toString() });
for (const l of cancelled)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'cancelled', actor: l.args.funder!, ts: tsOf(l.blockNumber) });
for (const l of disputed)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'disputed', actor: l.args.disputer!, ts: tsOf(l.blockNumber), detail: l.args.reasonRef });
for (const l of finalized)
activity.push({ bountyId: Number(l.args.bountyId), kind: 'finalized', actor: l.args.worker!, ts: tsOf(l.blockNumber) });
activity.sort((a, b) => b.ts - a.ts);
// Stats — total paid + avg time-to-payment from events.
let totalPaidWei = 0n;
for (const l of paid) totalPaidWei += l.args.reward ?? 0n;
const proposedTs = new Map<number, number>();
for (const l of proposed) proposedTs.set(Number(l.args.bountyId), tsOf(l.blockNumber));
const deltas: number[] = [];
for (const l of paid) {
const id = Number(l.args.bountyId);
const p = proposedTs.get(id);
const paidAt = tsOf(l.blockNumber);
if (p && paidAt && paidAt >= p) deltas.push(paidAt - p);
}
const avgTimeToPaymentSec = deltas.length ? Math.round(deltas.reduce((a, b) => a + b, 0) / deltas.length) : 0;
// Distinct on-chain participants (workers + funders + approvers).
const people = new Set<string>();
for (const b of bountyStructs) {
for (const a of [b.worker, b.funder, b.approver]) if (!isZero(a)) people.add(a.toLowerCase());
}
for (const r of reviewers) people.add(r.toLowerCase());
const contributors = buildContributors(participants, repByWorker, reviewedBy);
return {
bounties,
activity,
stats: {
totalPaidWei,
paidCount: paid.length,
avgTimeToPaymentSec,
contributorCount: people.size,
},
contributors,
};
}
async function reputationFor(workers: Address[]): Promise<Map<string, Reputation>> {
const entries = await Promise.all(
workers.map(async (w) => {
const [completed, earned] = await Promise.all([
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'completedOf', args: [w] }),
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'earnedOf', args: [w] }),
]);
let karma: bigint | undefined;
if (KARMA) {
try {
karma = (await client.readContract({ address: KARMA, abi: karmaAbi, functionName: 'karmaOf', args: [w] })) as bigint;
} catch {
karma = undefined; // Karma not yet canonical on this chain — degrade cleanly.
}
}
return [w.toLowerCase(), { completed: completed as bigint, earned: earned as bigint, karma }] as const;
}),
);
return new Map(entries);
}
function buildContributors(
workers: Address[],
rep: Map<string, Reputation>,
reviewedBy: Map<string, number>,
): Contributor[] {
const rows: Contributor[] = workers.map((w) => {
const r = rep.get(w.toLowerCase()) ?? { completed: 0n, earned: 0n };
return {
address: w,
completed: r.completed,
earned: r.earned,
karma: r.karma,
reviewed: reviewedBy.get(w.toLowerCase()) ?? 0,
points: r.completed * 100n, // transparent derived metric (tasks × 100)
};
});
rows.sort((a, b) => (b.completed === a.completed ? Number(b.earned - a.earned) : Number(b.completed - a.completed)));
return rows;
}
// Optional NFT reward metadata, resolved from an on-chain data: tokenURI/uri only
// (CSP-safe — no external fetch). http/ipfs URIs are skipped (placeholder shown).
export async function loadNftMeta(token: Address, tokenId: bigint, erc1155: boolean): Promise<NftMeta | undefined> {
try {
const uri = (await client.readContract({
address: token,
abi: nftMetaAbi,
functionName: erc1155 ? 'uri' : 'tokenURI',
args: [tokenId],
})) as string;
let collection: string | undefined;
try {
collection = (await client.readContract({ address: token, abi: nftMetaAbi, functionName: 'name' })) as string;
} catch {
collection = undefined;
}
if (uri.startsWith('data:application/json')) {
const json = uri.startsWith('data:application/json;base64,')
? atob(uri.slice('data:application/json;base64,'.length))
: decodeURIComponent(uri.slice(uri.indexOf(',') + 1));
const meta = JSON.parse(json) as { name?: string; image?: string };
return { name: meta.name, image: meta.image?.startsWith('data:') ? meta.image : undefined, collection };
}
return { collection };
} catch {
return undefined;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { LANES, Lane } from '../types';
import type { LaneDef, Task } from '../types';
import { isZero } from '../format';
import { openTask } from '../router';
import {
Avatar,
SkillTag,
RewardBadge,
OpenToBadge,
IconDoc,
IconCircle,
IconHalfCircle,
IconCheckCircle,
IconUsersPlus,
IconBolt,
IconShield,
IconCoins,
} from '../ui';
function LaneIcon({ lane }: { lane: Lane }) {
switch (lane) {
case Lane.ToDo:
return <IconCircle className="h-4 w-4 text-neutral-500" />;
case Lane.InProgress:
return <IconHalfCircle className="h-4 w-4 text-amber-400" />;
case Lane.InReview:
return <span className="inline-block h-3.5 w-3.5 rounded-full bg-blue-500 ring-2 ring-inset ring-blue-300/40" />;
case Lane.Done:
return <IconCheckCircle className="h-4 w-4 text-emerald-400" />;
}
}
function EmptyIcon({ icon }: { icon: LaneDef['icon'] }) {
const cls = 'h-6 w-6 text-neutral-400';
const map = { invite: <IconUsersPlus className={cls} />, bolt: <IconBolt className={cls} />, review: <IconShield className={cls} />, pay: <IconCoins className={cls} /> };
return <span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/5">{map[icon]}</span>;
}
export function TaskCard({ task }: { task: Task }) {
const assigned = !isZero(task.worker);
return (
<button
onClick={() => openTask(task.id)}
className="group flex w-full flex-col gap-2 rounded-lg bg-[#1e1e22] p-3 text-left ring-1 ring-inset ring-white/8 transition-colors hover:bg-[#26262b]"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-snug text-neutral-100 group-hover:text-white">{task.title}</span>
<IconDoc className="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
</div>
{task.skills.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{task.skills.map((s) => (
<SkillTag key={s} skill={s} compact />
))}
</div>
)}
<div className="flex items-center justify-between gap-2 pt-0.5">
<div className="flex items-center gap-2">
<RewardBadge reward={task.parsedReward} />
<OpenToBadge openTo={task.openTo} />
</div>
{assigned && <Avatar addr={task.worker} size={20} />}
</div>
</button>
);
}
function Column({ def, tasks }: { def: LaneDef; tasks: Task[] }) {
return (
<section className="flex w-full min-w-0 flex-col rounded-xl bg-[#151518] ring-1 ring-inset ring-white/6">
<div className="flex items-center gap-2 px-3 py-2.5">
<LaneIcon lane={def.lane} />
<h2 className="text-sm font-semibold text-neutral-200">{def.title}</h2>
<span className="ml-1 rounded bg-white/6 px-1.5 text-xs tabular-nums text-neutral-400">{tasks.length}</span>
</div>
<div className="flex flex-1 flex-col gap-2 px-2 pb-3">
{tasks.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-3 py-8 text-center">
<EmptyIcon icon={def.icon} />
<p className="max-w-[13rem] text-xs leading-relaxed text-neutral-500">{def.empty}</p>
</div>
) : (
tasks.map((t) => <TaskCard key={t.id} task={t} />)
)}
</div>
</section>
);
}
export function Board({ tasks }: { tasks: Task[] }) {
const byLane = (lane: Lane) => tasks.filter((t) => t.lane === lane);
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{LANES.map((def) => (
<Column key={def.lane} def={def} tasks={byLane(def.lane)} />
))}
</div>
);
}
-103
View File
@@ -1,103 +0,0 @@
import type { BountyView } from '../types';
import { STATE_TITLE } from '../types';
import { ACCENT } from '../theme';
import { formatAmount, isZero, refToLink, relTime, short, tokenLabel } from '../format';
function AddrRow({ label, addr }: { label: string; addr: string }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-neutral-500">{label}</span>
<span className="font-mono text-neutral-300" title={addr}>
{short(addr)}
</span>
</div>
);
}
function RefRow({ label, value }: { label: string; value: string }) {
const { href, text } = refToLink(value);
return (
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-neutral-500 shrink-0">{label}</span>
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="truncate text-sky-400 hover:text-sky-300 underline decoration-dotted underline-offset-2"
>
{text}
</a>
) : (
<span className="truncate text-neutral-300">{text}</span>
)}
</div>
);
}
export function BountyCard({ b }: { b: BountyView }) {
const accent = ACCENT[b.state as keyof typeof ACCENT];
const claimed = !isZero(b.worker);
const hasDeadline = b.claimDeadline > 0n || b.reviewDeadline > 0n;
return (
<article className="rounded-lg bg-neutral-900 ring-1 ring-inset ring-neutral-800 hover:ring-neutral-700 transition-colors p-3 flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<span className="font-mono text-sm text-neutral-400">#{b.id}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${accent.badge}`}>{STATE_TITLE[b.state as keyof typeof STATE_TITLE]}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold text-neutral-100 tabular-nums">{formatAmount(b.reward)}</span>
<span className="text-xs text-neutral-500">{tokenLabel(b.token)}</span>
<span className="ml-auto text-xs text-neutral-500 tabular-nums">stake {formatAmount(b.stake)}</span>
</div>
<div className="flex flex-col gap-1 text-xs">
<AddrRow label="funder" addr={b.funder} />
<AddrRow label="approver" addr={b.approver} />
<div className="flex items-center justify-between gap-2">
<span className="text-neutral-500">worker</span>
{claimed ? (
<span className="font-mono text-neutral-300" title={b.worker}>
{short(b.worker)}
</span>
) : (
<span className="text-neutral-600 italic">unclaimed</span>
)}
</div>
</div>
{claimed && (
<div
className="rounded-md bg-emerald-500/10 ring-1 ring-inset ring-emerald-500/30 px-2.5 py-1.5"
title="Portable on-chain reputation (ReputationV1) — travels with the worker's address, not the platform"
>
<div className="flex items-center gap-1.5">
<span className="text-emerald-400">✓</span>
<span className="text-[10px] uppercase tracking-wider text-emerald-500/80 font-semibold">
on-chain rep
</span>
<span className="ml-auto text-xs text-emerald-200 tabular-nums">
{b.reputation.completed.toString()} completed · {formatAmount(b.reputation.earned)} earned
</span>
</div>
</div>
)}
{(b.issueRef || b.deliverableRef) && (
<div className="flex flex-col gap-1 text-xs">
{b.issueRef && <RefRow label="issue" value={b.issueRef} />}
{b.deliverableRef && <RefRow label="PR" value={b.deliverableRef} />}
</div>
)}
{hasDeadline && (
<div className="flex items-center gap-3 text-[11px] text-neutral-500 pt-1.5 border-t border-neutral-800/70">
{b.claimDeadline > 0n && <span>claim {relTime(b.claimDeadline)}</span>}
{b.reviewDeadline > 0n && <span>review {relTime(b.reviewDeadline)}</span>}
</div>
)}
</article>
);
}
-23
View File
@@ -1,23 +0,0 @@
import type { BountyView, ColumnDef } from '../types';
import { ACCENT } from '../theme';
import { BountyCard } from './BountyCard';
export function Column({ def, bounties }: { def: ColumnDef; bounties: BountyView[] }) {
const accent = ACCENT[def.state];
return (
<section className="w-80 shrink-0 flex flex-col">
<div className="flex items-center gap-2 px-1 pb-2">
<span className={`h-2 w-2 rounded-full ${accent.dot}`} />
<h2 className="text-sm font-medium text-neutral-200">{def.title}</h2>
<span className="ml-auto text-xs text-neutral-500 tabular-nums">{bounties.length}</span>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-neutral-900/30 ring-1 ring-inset ring-neutral-800/70 p-2 min-h-24">
{bounties.length === 0 ? (
<div className="text-xs text-neutral-600 px-2 py-6 text-center select-none">—</div>
) : (
bounties.map((b) => <BountyCard key={b.id} b={b} />)
)}
</div>
</section>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { ORG } from '../config';
import { BRANDS } from '../brands';
import type { Task } from '../types';
import { SKILLS, skillMeta } from '../skills';
import { TaskCard } from './Board';
import { IconExternal } from '../ui';
// The global explorer. A single-brand build can only read its own chain (CSP), so
// this indexes THIS org's bounties across every Space (skill-filterable) and links
// out to each sibling DAO board — the honest cross-org index until a multi-chain
// aggregator API lands, at which point the grid federates without a UI change.
export function Explore({ tasks }: { tasks: Task[] }) {
const [active, setActive] = useState<string[]>([]);
const toggle = (s: string) => setActive((a) => (a.includes(s) ? a.filter((x) => x !== s) : [...a, s]));
const shown = active.length ? tasks.filter((t) => t.skills.some((s) => active.includes(s))) : tasks;
const others = (Object.keys(BRANDS) as (keyof typeof BRANDS)[]).filter((k) => BRANDS[k].workspace !== ORG.workspace);
return (
<div className="mx-auto max-w-6xl px-6 py-6">
<h1 className="text-2xl font-bold text-neutral-50">Explore bounties</h1>
<p className="mt-1 text-sm text-neutral-400">Browse open work across every Space in {ORG.workspace}, and jump to sibling DAOs.</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{SKILLS.map((s) => {
const on = active.includes(s);
const m = skillMeta(s);
return (
<button
key={s}
onClick={() => toggle(s)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-colors ${
on ? m.cls : 'text-neutral-400 ring-white/10 hover:bg-white/5'
}`}
>
<span>{m.emoji}</span> {s}
</button>
);
})}
</div>
{shown.length === 0 ? (
<p className="mt-10 text-sm text-neutral-500">No bounties match these skills.</p>
) : (
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((t) => (
<TaskCard key={t.id} task={t} />
))}
</div>
)}
<h2 className="mb-3 mt-10 text-lg font-semibold text-neutral-100">Other DAOs</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{others.map((k) => {
const b = BRANDS[k];
return (
<a
key={k}
href={`https://work.${k}.network`}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-3 rounded-xl bg-[#1a1a1e] p-4 ring-1 ring-inset ring-white/6 hover:bg-[#202025]"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg text-sm font-bold text-white" style={{ backgroundColor: b.accent }}>
{b.workspace.charAt(0)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-neutral-100">{b.workspace}</div>
<div className="truncate text-xs text-neutral-500">{b.tagline}</div>
</div>
<IconExternal className="h-4 w-4 text-neutral-500" />
</a>
);
})}
</div>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useMemo, useState } from 'react';
import type { Address } from 'viem';
import type { Workspace } from '../chain';
import type { Contributor } from '../types';
import { formatAmount, short } from '../format';
import { Avatar, IconSearch, IconTrophy } from '../ui';
type Window = 'all' | '30d' | '7d';
// Windowed rows from the event stream (real, honest): tasks paid + earned by
// worker, and reviews by approver, both within the window. Includes reviewer-only
// addresses so the Reviewers table populates in 7d/30d too. All-time uses the
// cumulative Reputation totals (authoritative), so the default view can't drift.
function windowedContributors(ws: Workspace, days: number): Contributor[] {
const cut = Date.now() / 1000 - days * 86400;
const acc = new Map<string, { addr: Address; done: bigint; earned: bigint; reviewed: number }>();
const get = (a: Address) => {
const key = a.toLowerCase();
const row = acc.get(key) ?? { addr: a, done: 0n, earned: 0n, reviewed: 0 };
acc.set(key, row);
return row;
};
for (const a of ws.activity) {
if (a.ts < cut) continue;
if (a.kind === 'paid') {
const r = get(a.actor);
r.done += 1n;
r.earned += a.detail ? BigInt(a.detail) : 0n;
} else if (a.kind === 'accepted') {
get(a.actor).reviewed += 1;
}
}
return Array.from(acc.values())
.map((r) => ({
address: r.addr,
completed: r.done,
earned: r.earned,
karma: ws.contributors.find((c) => c.address.toLowerCase() === r.addr.toLowerCase())?.karma,
reviewed: r.reviewed,
points: r.done * 100n,
}))
.sort((a, b) => (b.completed === a.completed ? Number(b.earned - a.earned) : Number(b.completed - a.completed)));
}
function WindowToggle({ value, onChange }: { value: Window; onChange: (w: Window) => void }) {
const opts: [Window, string][] = [
['7d', '7d'],
['30d', '30d'],
['all', 'All time'],
];
return (
<div className="flex items-center gap-1">
{opts.map(([k, label]) => (
<button
key={k}
onClick={() => onChange(k)}
style={value === k ? { backgroundColor: 'var(--brand)' } : undefined}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
value === k ? 'text-white' : 'bg-white/5 text-neutral-400 hover:text-neutral-200'
}`}
>
{label}
</button>
))}
</div>
);
}
function Search({ placeholder }: { placeholder: string }) {
return (
<label className="flex items-center gap-1.5 rounded-md bg-white/5 px-2 py-1 text-xs text-neutral-400 ring-1 ring-inset ring-white/8">
<IconSearch className="h-3.5 w-3.5" />
<input placeholder={placeholder} className="w-36 bg-transparent text-neutral-200 placeholder:text-neutral-600 focus:outline-none" />
</label>
);
}
function Th({ children, right }: { children: React.ReactNode; right?: boolean }) {
return <th className={`px-3 py-2 text-xs font-medium text-neutral-500 ${right ? 'text-right' : 'text-left'}`}>{children}</th>;
}
function Empty() {
return (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-neutral-600">
<IconTrophy className="h-8 w-8 opacity-40" />
<span className="text-sm">No Data</span>
</div>
);
}
function ContributorTable({ rows, hasKarma }: { rows: Contributor[]; hasKarma: boolean }) {
if (rows.length === 0) return <Empty />;
return (
<table className="w-full">
<thead>
<tr className="border-b border-white/6">
<Th>Rank</Th>
<Th>Username</Th>
<Th right>Tasks Done</Th>
<Th right>Task Points</Th>
{hasKarma && <Th right>Karma</Th>}
<Th right>Earned</Th>
</tr>
</thead>
<tbody>
{rows.map((c, i) => (
<tr key={c.address} className="border-b border-white/4 hover:bg-white/3">
<td className="px-3 py-2.5 text-sm tabular-nums text-neutral-400">{i + 1}</td>
<td className="px-3 py-2.5">
<span className="flex items-center gap-2">
<Avatar addr={c.address} size={22} />
<span className="font-mono text-sm text-neutral-200">{short(c.address)}</span>
</span>
</td>
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-neutral-200">{c.completed.toString()}</td>
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-neutral-300">{c.points.toString()}</td>
{hasKarma && (
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-fuchsia-300" title="Global soul-bound Karma (portable across every DAO)">
{c.karma !== undefined ? formatAmount(c.karma) : '—'}
</td>
)}
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-emerald-300">{formatAmount(c.earned)}</td>
</tr>
))}
</tbody>
</table>
);
}
function ReviewerTable({ rows }: { rows: Contributor[] }) {
const reviewers = rows.filter((r) => r.reviewed > 0).sort((a, b) => b.reviewed - a.reviewed);
if (reviewers.length === 0) return <Empty />;
return (
<table className="w-full">
<thead>
<tr className="border-b border-white/6">
<Th>Rank</Th>
<Th>Username</Th>
<Th right>Tasks Reviewed</Th>
<Th right>Task Points</Th>
</tr>
</thead>
<tbody>
{reviewers.map((c, i) => (
<tr key={c.address} className="border-b border-white/4 hover:bg-white/3">
<td className="px-3 py-2.5 text-sm tabular-nums text-neutral-400">{i + 1}</td>
<td className="px-3 py-2.5">
<span className="flex items-center gap-2">
<Avatar addr={c.address} size={22} />
<span className="font-mono text-sm text-neutral-200">{short(c.address)}</span>
</span>
</td>
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-neutral-200">{c.reviewed}</td>
<td className="px-3 py-2.5 text-right text-sm tabular-nums text-neutral-300">{c.reviewed * 100}</td>
</tr>
))}
</tbody>
</table>
);
}
export function Leaderboards({ ws }: { ws: Workspace }) {
const [win, setWin] = useState<Window>('all');
// One windowed base set feeds both tables: Top Contributors filters to people
// who completed work; Top Reviewers (below) filters to reviewers. Reviewer-only
// addresses stay in the base so they surface in the Reviewers table.
const base = useMemo(
() => (win === 'all' ? ws.contributors : windowedContributors(ws, win === '7d' ? 7 : 30)),
[ws, win],
);
const contributorRows = useMemo(() => base.filter((c) => c.completed > 0n), [base]);
const hasKarma = ws.contributors.some((c) => c.karma !== undefined);
return (
<div className="mx-auto max-w-6xl px-6 py-6">
<h1 className="mb-5 text-2xl font-bold text-neutral-100">Leaderboards</h1>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div className="rounded-xl bg-[#151518] ring-1 ring-inset ring-white/6">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">Top Contributors</h2>
<WindowToggle value={win} onChange={setWin} />
<div className="ml-auto">
<Search placeholder="Search contributors..." />
</div>
</div>
<ContributorTable rows={contributorRows} hasKarma={hasKarma} />
</div>
<div className="rounded-xl bg-[#151518] ring-1 ring-inset ring-white/6">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">Top Reviewers</h2>
<WindowToggle value={win} onChange={setWin} />
<div className="ml-auto">
<Search placeholder="Search reviewers..." />
</div>
</div>
<ReviewerTable rows={base} />
</div>
</div>
<div className="mt-6 rounded-xl bg-[#151518] ring-1 ring-inset ring-white/6">
<div className="flex items-center justify-between px-4 py-3">
<h2 className="text-sm font-semibold text-neutral-200">All Contributors</h2>
<Search placeholder="Search contributors..." />
</div>
{ws.contributors.length === 0 ? (
<Empty />
) : (
<table className="w-full">
<thead>
<tr className="border-b border-white/6">
<Th>Username</Th>
<Th right>Roles</Th>
</tr>
</thead>
<tbody>
{ws.contributors.map((c) => (
<tr key={c.address} className="border-b border-white/4 hover:bg-white/3">
<td className="px-3 py-2.5">
<span className="flex items-center gap-2">
<Avatar addr={c.address} size={22} />
<span className="font-mono text-sm text-neutral-200">{short(c.address)}</span>
</span>
</td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex gap-1">
{c.reviewed > 0 && <span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[11px] text-blue-300 ring-1 ring-inset ring-blue-500/25">Reviewer</span>}
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/10">Contributor</span>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { LANES } from '../types';
import type { Lane, Task } from '../types';
import { isZero } from '../format';
import { openTask } from '../router';
import { Avatar, SkillTag, RewardBadge, OpenToBadge, IconDoc, IconCircle, IconChevronDown } from '../ui';
function TaskRow({ task }: { task: Task }) {
const assigned = !isZero(task.worker);
return (
<button
onClick={() => openTask(task.id)}
className="flex w-full items-center gap-3 border-b border-white/5 px-2 py-2.5 text-left transition-colors hover:bg-white/4"
>
<IconCircle className="h-4 w-4 shrink-0 text-neutral-600" />
<IconDoc className="h-4 w-4 shrink-0 text-sky-400" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-neutral-100">{task.title}</span>
<div className="hidden items-center gap-1.5 sm:flex">
{task.skills.map((s) => (
<SkillTag key={s} skill={s} />
))}
</div>
<RewardBadge reward={task.parsedReward} />
<div className="w-32 text-right">
<OpenToBadge openTo={task.openTo} />
</div>
<span className="w-6 shrink-0 text-right">{assigned && <Avatar addr={task.worker} size={20} />}</span>
</button>
);
}
function Group({ lane, title, tasks }: { lane: Lane; title: string; tasks: Task[] }) {
const rows = tasks.filter((t) => t.lane === lane);
if (rows.length === 0) return null;
return (
<div className="mb-4">
<div className="flex items-center gap-2 px-2 py-2 text-sm text-neutral-300">
<IconChevronDown className="h-4 w-4 text-neutral-500" />
<IconCircle className="h-4 w-4 text-neutral-500" />
<span className="font-medium">{title}</span>
<span className="rounded bg-white/6 px-1.5 text-xs tabular-nums text-neutral-400">{rows.length}</span>
</div>
<div>
{rows.map((t) => (
<TaskRow key={t.id} task={t} />
))}
</div>
</div>
);
}
export function OpenTasks({ tasks }: { tasks: Task[] }) {
const anyTasks = tasks.length > 0;
return (
<div>
{!anyTasks ? (
<div className="flex flex-col items-center justify-center gap-2 py-24 text-neutral-500">
<IconCircle className="h-6 w-6" />
<span className="text-sm">No open tasks yet.</span>
</div>
) : (
LANES.map((def) => <Group key={def.lane} lane={def.lane} title={def.title} tasks={tasks} />)
)}
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useMemo } from 'react';
import { ORG } from '../config';
import type { Workspace } from '../chain';
import type { Task } from '../types';
import { rollupBySpace } from '../tasks';
import { skillMeta } from '../skills';
import { isZero, short, usdFromWei, formatDuration } from '../format';
import { navigate } from '../router';
import {
AvatarStack,
StatPill,
IconClock,
IconDollar,
IconDiscord,
IconTwitter,
IconLink,
IconInfo,
IconUsersPlus,
} from '../ui';
import type { Space } from '../brands';
import type { SpaceRollup } from '../tasks';
function OrgHeader({ ws }: { ws: Workspace }) {
return (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<span
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: 'var(--brand)' }}
>
{ORG.workspace.charAt(0)}
</span>
<div>
<h1 className="text-3xl font-bold tracking-tight text-neutral-50">{ORG.workspace}</h1>
<p className="mt-0.5 text-sm text-neutral-400">{ORG.tagline}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatPill icon={<IconClock className="h-3.5 w-3.5" />}>
Time to payment: <span className="font-semibold text-neutral-100">{formatDuration(ws.stats.avgTimeToPaymentSec)}</span>
</StatPill>
<StatPill icon={<IconDollar className="h-3.5 w-3.5" />}>
Total paid: <span className="font-semibold text-neutral-100">{usdFromWei(ws.stats.totalPaidWei, ORG.usdPerNative)}</span>
</StatPill>
<div className="flex items-center gap-1 pl-1">
{ORG.social.discord && (
<a href={ORG.social.discord} target="_blank" rel="noreferrer noopener" className="rounded p-1.5 text-neutral-400 hover:bg-white/5 hover:text-neutral-200" title="Discord">
<IconDiscord className="h-4 w-4" />
</a>
)}
{ORG.social.twitter && (
<a href={ORG.social.twitter} target="_blank" rel="noreferrer noopener" className="rounded p-1.5 text-neutral-400 hover:bg-white/5 hover:text-neutral-200" title="Twitter / X">
<IconTwitter className="h-4 w-4" />
</a>
)}
{ORG.social.website && (
<a href={ORG.social.website} target="_blank" rel="noreferrer noopener" className="rounded p-1.5 text-neutral-400 hover:bg-white/5 hover:text-neutral-200" title="Website">
<IconLink className="h-4 w-4" />
</a>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function DiscordBanner() {
const href = ORG.iam?.discordUrl ?? ORG.social.discord ?? '#';
return (
<div className="rounded-xl bg-indigo-500/8 p-4 ring-1 ring-inset ring-indigo-400/20">
<div className="flex items-start gap-3">
<IconInfo className="mt-0.5 h-5 w-5 shrink-0 text-indigo-300" />
<div className="flex-1">
<p className="text-sm text-neutral-200">
The following Discord roles give you access to permissions and private Spaces in this organization. Connect with
Discord to get access.
</p>
<div className="mt-2.5 flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded bg-white/8 px-2 py-1 text-xs text-neutral-300 ring-1 ring-inset ring-white/10">
<IconDiscord className="h-3.5 w-3.5 text-indigo-300" /> {ORG.workspace.toLowerCase()} member
</span>
</div>
<a href={href} target="_blank" rel="noreferrer noopener" className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-[#5865F2] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90">
<IconDiscord className="h-4 w-4" /> Connect with Discord
</a>
</div>
</div>
</div>
);
}
function SpaceCard({ space, rollup }: { space: Space; rollup: SpaceRollup }) {
return (
<button
onClick={() => navigate(`/space/${space.key}/board`)}
className="flex flex-col gap-4 rounded-xl bg-[#1a1a1e] p-4 text-left ring-1 ring-inset ring-white/6 transition-colors hover:bg-[#202025]"
>
<div className="flex items-center gap-2">
<span className="text-xl leading-none">{space.emoji}</span>
<span className="text-sm font-semibold text-neutral-100">{space.name}</span>
</div>
<div className="flex items-center gap-1.5">
{space.skills.map((s) => (
<span key={s} className="text-sm" title={s}>
{skillMeta(s).emoji}
</span>
))}
</div>
<div className="flex items-center gap-3 text-xs text-neutral-500">
<span className="tabular-nums">{rollup.open} open tasks</span>
<span>·</span>
<span className="tabular-nums">{rollup.contributors} contributors</span>
</div>
</button>
);
}
function RightRail({ ws, tasks }: { ws: Workspace; tasks: Task[] }) {
const contributors = useMemo(() => {
const set = new Set<string>();
const list: string[] = [];
for (const t of tasks) {
for (const a of [t.worker, t.funder, t.approver]) {
const k = a.toLowerCase();
if (!isZero(a) && !set.has(k)) {
set.add(k);
list.push(a);
}
}
}
return list;
}, [tasks]);
const admins = [ORG.addresses.owner, ...(ORG.addresses.governor ? [ORG.addresses.governor] : [])];
return (
<div className="flex w-72 shrink-0 flex-col gap-6">
<section>
<h3 className="mb-2 text-sm font-semibold text-neutral-200">About</h3>
<p className="text-sm text-neutral-400">{ORG.tagline}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{ORG.tags.map((t) => (
<span key={t} className="rounded bg-white/6 px-2 py-0.5 text-xs text-neutral-300 ring-1 ring-inset ring-white/8">
{t}
</span>
))}
</div>
</section>
<section>
<h3 className="mb-2.5 text-sm font-semibold text-neutral-200">
Contributors <span className="text-neutral-500">{ws.stats.contributorCount}</span>
</h3>
{contributors.length > 0 ? (
<AvatarStack addrs={contributors} max={8} />
) : (
<p className="text-xs text-neutral-600">No contributors yet.</p>
)}
<button className="mt-3 flex items-center gap-1.5 rounded-md bg-white/5 px-2.5 py-1.5 text-xs font-medium text-neutral-300 ring-1 ring-inset ring-white/8 hover:bg-white/10">
<IconUsersPlus className="h-3.5 w-3.5" /> Invite Contributors
</button>
</section>
<section>
<h3 className="mb-2.5 text-sm font-semibold text-neutral-200">Admins</h3>
<div className="flex items-center gap-2">
<AvatarStack addrs={admins} max={4} />
<span className="text-xs text-neutral-500" title={ORG.addresses.owner}>
{ORG.ownerLabel} · {short(ORG.addresses.owner)}
</span>
</div>
</section>
</div>
);
}
export function Overview({ ws, tasks }: { ws: Workspace; tasks: Task[] }) {
const rollups = useMemo(() => rollupBySpace(tasks, ORG.spaces), [tasks]);
const bountySpaces = ORG.spaces.filter((s) => !s.project);
const projectSpaces = ORG.spaces.filter((s) => s.project);
const empty = { open: 0, contributors: 0, total: 0 };
return (
<div className="px-8 py-6">
<OrgHeader ws={ws} />
<div className="mt-6 flex gap-8">
<div className="min-w-0 flex-1">
<DiscordBanner />
<h2 className="mb-3 mt-7 text-lg font-semibold text-neutral-100">Bounties</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{bountySpaces.map((s) => (
<SpaceCard key={s.key} space={s} rollup={rollups.get(s.key) ?? empty} />
))}
</div>
{projectSpaces.length > 0 && (
<>
<h2 className="mb-3 mt-7 text-lg font-semibold text-neutral-100">Projects based bounties</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{projectSpaces.map((s) => (
<SpaceCard key={s.key} space={s} rollup={rollups.get(s.key) ?? empty} />
))}
</div>
</>
)}
</div>
<RightRail ws={ws} tasks={tasks} />
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { ReactNode } from 'react';
import { ORG } from '../config';
import { useRoute, navigate } from '../router';
import type { Route } from '../router';
import {
IconHome,
IconPlus,
IconGrid,
IconBulb,
IconTrophy,
IconBoard,
IconChevronsLeft,
IconQuestion,
IconMail,
IconDoc,
} from '../ui';
function NavItem({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors ${
active ? 'bg-white/8 font-medium text-neutral-100' : 'text-neutral-400 hover:bg-white/5 hover:text-neutral-200'
}`}
>
<span className={active ? 'text-neutral-200' : 'text-neutral-500'}>{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
function isSpace(route: Route, key: string): boolean {
return route.view === 'space' && route.spaceKey === key;
}
export function Sidebar() {
const route = useRoute();
const spaces = ORG.spaces.filter((s) => !s.project);
const projects = ORG.spaces.filter((s) => s.project);
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-[#26262b] bg-[#141417]">
{/* org switcher rail */}
<div className="flex items-center gap-2 px-3 pt-3">
<button
onClick={() => navigate('/')}
style={{ backgroundColor: 'var(--brand)' }}
className="flex h-9 w-9 items-center justify-center rounded-xl text-white shadow-sm"
title="Home"
>
<IconHome className="h-4.5 w-4.5" />
</button>
<button
onClick={() => navigate('/explore')}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/5 text-neutral-400 ring-1 ring-inset ring-white/8 hover:text-neutral-200"
title="Browse bounties across all DAOs"
>
<IconPlus className="h-4.5 w-4.5" />
</button>
</div>
{/* workspace */}
<button onClick={() => navigate('/')} className="mx-3 mt-3 flex items-center gap-2.5 rounded-md px-1 py-1 text-left hover:bg-white/5">
<span
className="flex h-7 w-7 items-center justify-center rounded-lg text-sm font-bold text-white"
style={{ backgroundColor: 'var(--brand)' }}
>
{ORG.workspace.charAt(0)}
</span>
<span className="flex-1 truncate text-sm font-semibold text-neutral-100">{ORG.workspace}</span>
<IconChevronsLeft className="h-4 w-4 text-neutral-600" />
</button>
{/* New Suggestion */}
<div className="px-3 pt-3">
<button
onClick={() => navigate('/suggestions?new=1')}
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-white/6 px-3 py-1.5 text-sm font-medium text-neutral-200 ring-1 ring-inset ring-white/10 hover:bg-white/10"
>
<IconPlus className="h-4 w-4" /> New Suggestion
</button>
</div>
{/* primary nav */}
<nav className="mt-3 flex flex-col gap-0.5 px-3">
<NavItem active={route.view === 'overview'} icon={<IconGrid className="h-4 w-4" />} label="Overview" onClick={() => navigate('/')} />
<NavItem active={route.view === 'suggestions'} icon={<IconBulb className="h-4 w-4" />} label="Community Suggestions" onClick={() => navigate('/suggestions')} />
<NavItem active={route.view === 'leaderboards'} icon={<IconTrophy className="h-4 w-4" />} label="Leaderboards" onClick={() => navigate('/leaderboards')} />
<NavItem active={route.view === 'board'} icon={<IconBoard className="h-4 w-4" />} label="Combined Board" onClick={() => navigate('/board')} />
</nav>
{/* bounties (spaces) */}
<div className="mt-5 px-3">
<div className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">Bounties</div>
<nav className="flex flex-col gap-0.5">
{spaces.map((s) => (
<NavItem
key={s.key}
active={isSpace(route, s.key)}
icon={<span className="text-[15px] leading-none">{s.emoji}</span>}
label={s.name}
onClick={() => navigate(`/space/${s.key}/board`)}
/>
))}
</nav>
</div>
{projects.length > 0 && (
<div className="mt-5 px-3">
<div className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">Projects Based Bounties</div>
<nav className="flex flex-col gap-0.5">
{projects.map((s) => (
<NavItem
key={s.key}
active={isSpace(route, s.key)}
icon={<span className="text-[15px] leading-none">{s.emoji}</span>}
label={s.name}
onClick={() => navigate(`/space/${s.key}/board`)}
/>
))}
</nav>
</div>
)}
{/* footer */}
<div className="mt-auto flex flex-col gap-0.5 px-3 pb-4 pt-6">
<a href={ORG.social.website ?? '#'} target="_blank" rel="noreferrer noopener" className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-neutral-500 hover:bg-white/5 hover:text-neutral-300">
<IconQuestion className="h-4 w-4" /> Ask a question
</a>
<a href={ORG.social.website ?? '#'} target="_blank" rel="noreferrer noopener" className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-neutral-500 hover:bg-white/5 hover:text-neutral-300">
<IconMail className="h-4 w-4" /> Give us feedback
</a>
<a href={ORG.social.website ?? '#'} target="_blank" rel="noreferrer noopener" className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-neutral-500 hover:bg-white/5 hover:text-neutral-300">
<IconDoc className="h-4 w-4" /> Read our docs
</a>
</div>
</aside>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useMemo, useState } from 'react';
import { ORG } from '../config';
import type { Task } from '../types';
import { LANES } from '../types';
import type { Tab } from '../router';
import { navigate } from '../router';
import { isZero } from '../format';
import { Board } from './Board';
import { OpenTasks } from './OpenTasks';
import { Suggestions } from './Suggestions';
import { Tabs, Toolbar } from './chrome';
import { AvatarStack, SkillTag, IconGrid, IconBoard, IconList, IconBulb } from '../ui';
import type { TabDef } from './chrome';
const TAB_DEFS: TabDef[] = [
{ key: 'overview', label: 'Overview', icon: <IconGrid className="h-4 w-4" /> },
{ key: 'board', label: 'Board', icon: <IconBoard className="h-4 w-4" /> },
{ key: 'tasks', label: 'Open Tasks', icon: <IconList className="h-4 w-4" /> },
{ key: 'suggestions', label: 'Community Suggestions', icon: <IconBulb className="h-4 w-4" /> },
];
function SpaceOverview({ tasks, spaceSkills }: { tasks: Task[]; spaceSkills: string[] }) {
const counts = LANES.map((l) => ({ title: l.title, n: tasks.filter((t) => t.lane === l.lane).length }));
const contributors = useMemo(() => {
const set = new Set<string>();
const out: string[] = [];
for (const t of tasks) if (!isZero(t.worker) && !set.has(t.worker.toLowerCase())) (set.add(t.worker.toLowerCase()), out.push(t.worker));
return out;
}, [tasks]);
return (
<div className="max-w-2xl">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{counts.map((c) => (
<div key={c.title} className="rounded-lg bg-[#1a1a1e] p-3 ring-1 ring-inset ring-white/6">
<div className="text-2xl font-bold tabular-nums text-neutral-100">{c.n}</div>
<div className="text-xs text-neutral-500">{c.title}</div>
</div>
))}
</div>
<div className="mt-5">
<div className="mb-2 text-sm font-semibold text-neutral-200">Skills</div>
<div className="flex flex-wrap gap-1.5">
{spaceSkills.map((s) => (
<SkillTag key={s} skill={s} />
))}
</div>
</div>
<div className="mt-5">
<div className="mb-2 text-sm font-semibold text-neutral-200">Contributors</div>
{contributors.length > 0 ? <AvatarStack addrs={contributors} max={10} /> : <p className="text-xs text-neutral-600">None yet.</p>}
</div>
</div>
);
}
export function SpaceView({ tasks, spaceKey, tab }: { tasks: Task[]; spaceKey: string; tab: Tab }) {
const [query, setQuery] = useState('');
const space = ORG.spaces.find((s) => s.key === spaceKey);
const spaceTasks = tasks.filter((t) => t.spaceKey === spaceKey);
const shown = query ? spaceTasks.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) : spaceTasks;
if (!space) {
return <div className="p-8 text-sm text-neutral-500">Unknown space.</div>;
}
return (
<div className="px-8 py-6">
<div className="mb-1 flex items-center gap-2">
<span className="text-2xl leading-none">{space.emoji}</span>
<h1 className="text-2xl font-bold text-neutral-50">{space.name}</h1>
</div>
<div className="mb-6 mt-3 flex items-center justify-between border-b border-white/6 pr-32">
<Tabs tabs={TAB_DEFS} active={tab} onSelect={(k) => navigate(`/space/${spaceKey}/${k}`)} />
{(tab === 'board' || tab === 'tasks') && (
<div className="pb-2">
<Toolbar query={query} onQuery={setQuery} />
</div>
)}
</div>
{tab === 'overview' && <SpaceOverview tasks={spaceTasks} spaceSkills={space.skills} />}
{tab === 'board' && <Board tasks={shown} />}
{tab === 'tasks' && <OpenTasks tasks={shown} />}
{tab === 'suggestions' && <Suggestions />}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { ORG } from '../config';
import { PrimaryButton, IconPlus, IconChevronDown, IconBulb, IconX } from '../ui';
import { timeAgo } from '../format';
// Community Suggestions — governance ideation. Persisted client-side (localStorage,
// keyed per brand) so it is genuinely functional today and survives reloads; the
// same shape swaps to an IAM-backed or on-chain store later without a UI change.
type Suggestion = { id: string; title: string; body: string; votes: number; ts: number; voted: boolean };
const KEY = `work-suggestions:${ORG.workspace}`;
function load(): Suggestion[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Suggestion[]) : [];
} catch {
return [];
}
}
function save(list: Suggestion[]) {
try {
localStorage.setItem(KEY, JSON.stringify(list));
} catch {
/* storage unavailable — in-memory only for this session */
}
}
function NewSuggestionModal({ onClose, onCreate }: { onClose: () => void; onCreate: (title: string, body: string) => void }) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 px-4 pt-28" onClick={onClose}>
<div className="w-full max-w-2xl rounded-xl bg-[#1c1c20] p-5 shadow-2xl ring-1 ring-inset ring-white/10" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter your suggestion here"
className="w-full bg-transparent text-lg font-semibold text-neutral-100 placeholder:text-neutral-500 focus:outline-none"
/>
<button onClick={onClose} className="ml-3 rounded p-1 text-neutral-500 hover:bg-white/5 hover:text-neutral-300">
<IconX className="h-4 w-4" />
</button>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder='Add more details about your suggestion here. Type "/" to insert.'
rows={4}
className="mt-3 w-full resize-none bg-transparent text-sm text-neutral-300 placeholder:text-neutral-600 focus:outline-none"
/>
<div className="mt-4">
<PrimaryButton
className="w-full py-2.5"
onClick={() => {
if (title.trim()) onCreate(title.trim(), body.trim());
}}
>
Create
</PrimaryButton>
</div>
</div>
</div>
);
}
export function Suggestions() {
const [list, setList] = useState<Suggestion[]>(() => load());
const [modal, setModal] = useState(false);
// Sidebar "New Suggestion" links to /suggestions?new=1 — open the modal on mount.
useEffect(() => {
if (new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('new') === '1') setModal(true);
}, []);
useEffect(() => save(list), [list]);
const create = (title: string, body: string) => {
setList((l) => [{ id: crypto.randomUUID(), title, body, votes: 1, ts: Date.now() / 1000, voted: true }, ...l]);
setModal(false);
};
const toggleVote = (id: string) =>
setList((l) => l.map((s) => (s.id === id ? { ...s, voted: !s.voted, votes: s.votes + (s.voted ? -1 : 1) } : s)));
const trending = [...list].sort((a, b) => b.votes - a.votes);
return (
<div className="mx-auto max-w-4xl px-6 py-6">
<div className="mb-5 flex items-center justify-between">
<button className="flex items-center gap-1.5 text-lg font-semibold text-neutral-100">
Trending <IconChevronDown className="h-4 w-4 text-neutral-500" />
</button>
<PrimaryButton onClick={() => setModal(true)}>
<IconPlus className="h-4 w-4" /> Add a suggestion
</PrimaryButton>
</div>
{trending.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-neutral-500">
<IconBulb className="h-8 w-8 opacity-40" />
<p className="text-sm">No suggestions yet — be the first to propose one.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{trending.map((s) => (
<div key={s.id} className="flex items-start gap-3 rounded-lg bg-[#1a1a1e] p-3 ring-1 ring-inset ring-white/6">
<button
onClick={() => toggleVote(s.id)}
className={`flex w-12 shrink-0 flex-col items-center rounded-md py-1.5 ring-1 ring-inset transition-colors ${
s.voted ? 'bg-white/8 ring-white/15 text-neutral-100' : 'ring-white/8 text-neutral-400 hover:bg-white/5'
}`}
style={s.voted ? { color: 'var(--brand)' } : undefined}
>
<span className="text-xs">▲</span>
<span className="text-sm font-semibold tabular-nums">{s.votes}</span>
</button>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-neutral-100">{s.title}</div>
{s.body && <div className="mt-0.5 text-sm text-neutral-400">{s.body}</div>}
<div className="mt-1 text-xs text-neutral-600">{timeAgo(s.ts)}</div>
</div>
</div>
))}
</div>
)}
{modal && <NewSuggestionModal onClose={() => setModal(false)} onCreate={create} />}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useEffect, useState } from 'react';
import { ORG } from '../config';
import type { Workspace } from '../chain';
import { loadNftMeta } from '../chain';
import type { NftMeta } from '../reward';
import { isNft } from '../reward';
import type { Task, Activity, ActivityKind } from '../types';
import { STATE_TITLE, State, RewardType } from '../types';
import { closeTask } from '../router';
import { short, isZero, absDate, timeAgo, refToLink, formatAmount } from '../format';
import {
Avatar,
SkillTag,
RewardBadge,
OpenToBadge,
PrimaryButton,
IconX,
IconDots,
IconBookmark,
IconTwitter,
IconInfo,
IconLock,
IconExternal,
} from '../ui';
const VERB: Record<ActivityKind, string> = {
proposed: 'created this task',
funded: 'funded the bounty',
claimed: 'claimed the task',
submitted: 'submitted work',
accepted: 'accepted the work',
paid: 'was paid',
disputed: 'opened a dispute',
resolved: 'resolved the dispute',
cancelled: 'cancelled the bounty',
slashed: 'stake slashed',
finalized: 'finalized the task',
};
type Comment = { id: string; author: string; body: string; ts: number };
function commentsKey(id: number) {
return `work-comments:${ORG.workspace}:${id}`;
}
function loadComments(id: number): Comment[] {
try {
return JSON.parse(localStorage.getItem(commentsKey(id)) ?? '[]') as Comment[];
} catch {
return [];
}
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">{label}</div>
{children}
</div>
);
}
export function TaskDetail({ task, ws }: { task: Task; ws: Workspace }) {
const [meta, setMeta] = useState<NftMeta | undefined>();
const [comments, setComments] = useState<Comment[]>(() => loadComments(task.id));
const [draft, setDraft] = useState('');
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (isNft(task.parsedReward) && (task.parsedReward.type === RewardType.ERC721 || task.parsedReward.type === RewardType.ERC1155)) {
const r = task.parsedReward;
void loadNftMeta(r.token, r.tokenId, r.type === RewardType.ERC1155).then(setMeta);
}
}, [task.id]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && closeTask();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const activity = ws.activity.filter((a) => a.bountyId === task.id).sort((a, b) => a.ts - b.ts);
const assigned = !isZero(task.worker);
const reviewer = !isZero(task.approver) ? task.approver : !isZero(task.arbiter) ? task.arbiter : null;
const issue = task.issueRef ? refToLink(task.issueRef) : null;
const deliverable = task.deliverableRef ? refToLink(task.deliverableRef) : null;
const addComment = () => {
if (!draft.trim()) return;
const next = [...comments, { id: crypto.randomUUID(), author: 'you', body: draft.trim(), ts: Date.now() / 1000 }];
setComments(next);
try {
localStorage.setItem(commentsKey(task.id), JSON.stringify(next));
} catch {
/* ignore */
}
setDraft('');
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 px-4 py-10 overflow-y-auto" onClick={closeTask}>
<div className="w-full max-w-4xl rounded-xl bg-[#161619] shadow-2xl ring-1 ring-inset ring-white/10" onClick={(e) => e.stopPropagation()}>
{/* header */}
<div className="flex items-start justify-between gap-3 border-b border-white/6 px-6 py-4">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs text-neutral-500">
<span>{ORG.workspace}</span>
<span>/</span>
<span className="capitalize">{task.spaceKey}</span>
<span>/</span>
<span>#{task.id}</span>
</div>
<div className="mt-1 flex items-center gap-2">
<h2 className="text-xl font-semibold text-neutral-50">{task.title}</h2>
<IconBookmark className="h-4 w-4 text-neutral-500" />
{ORG.social.twitter && <IconTwitter className="h-4 w-4 text-neutral-500" />}
</div>
</div>
<div className="flex items-center gap-1">
<button className="rounded p-1.5 text-neutral-500 hover:bg-white/5"><IconDots className="h-4 w-4" /></button>
<button onClick={closeTask} className="rounded p-1.5 text-neutral-500 hover:bg-white/5"><IconX className="h-4 w-4" /></button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 p-6 md:grid-cols-[1fr_240px]">
{/* main */}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
{task.openTo && <OpenToBadge openTo={task.openTo} />}
<RewardBadge reward={task.parsedReward} meta={meta} />
{task.skills.map((s) => (
<SkillTag key={s} skill={s} />
))}
</div>
{task.openTo && !dismissed && (
<div className="mt-4 rounded-lg bg-sky-500/8 p-4 ring-1 ring-inset ring-sky-400/20">
<div className="flex items-start justify-between gap-3">
<div className="flex gap-2.5">
<IconInfo className="mt-0.5 h-4 w-4 shrink-0 text-sky-300" />
<div>
<p className="text-sm font-medium text-neutral-100">
This task is {task.openTo === 'bids' ? 'Open to Bids' : 'Open to Applications'}.
</p>
<p className="mt-1 text-sm leading-relaxed text-neutral-400">
Click "I'm Interested" to express your interest to work on this task. If you're a good fit, the reviewer
will assign you; then you can claim, submit your work and get paid on-chain.
</p>
</div>
</div>
<button onClick={() => setDismissed(true)} className="text-xs text-neutral-500 hover:text-neutral-300">Dismiss ✕</button>
</div>
</div>
)}
{/* description — the on-chain facts + the linked off-chain spec (issueRef) */}
<div className="mt-5 border-l-2 border-white/10 pl-4 text-sm leading-relaxed text-neutral-300">
<p className="text-neutral-400">
On-chain bounty <span className="font-mono text-neutral-300">#{task.id}</span> on {ORG.chainName} · escrowed reward{' '}
<span className="text-emerald-300">{formatAmount(task.reward)} {task.token === '0x0000000000000000000000000000000000000000' ? ORG.nativeSymbol : short(task.token)}</span>
{task.stake > 0n && <> · claim stake <span className="text-neutral-200">{formatAmount(task.stake)}</span></>}.
</p>
{issue && (
<p className="mt-2">
Spec:{' '}
{issue.href ? (
<a href={issue.href} target="_blank" rel="noreferrer noopener" className="inline-flex items-center gap-1 text-sky-400 hover:text-sky-300">
{issue.text} <IconExternal className="h-3 w-3" />
</a>
) : (
<span className="text-neutral-200">{issue.text}</span>
)}
</p>
)}
{deliverable && (
<p className="mt-1">
Deliverable:{' '}
{deliverable.href ? (
<a href={deliverable.href} target="_blank" rel="noreferrer noopener" className="inline-flex items-center gap-1 text-sky-400 hover:text-sky-300">
{deliverable.text} <IconExternal className="h-3 w-3" />
</a>
) : (
<span className="text-neutral-200">{deliverable.text}</span>
)}
</p>
)}
</div>
{task.state === State.Open || task.state === State.Funded ? (
<div className="mt-5">
<PrimaryButton onClick={addComment}>
<IconLock className="h-4 w-4" /> I'm interested
</PrimaryButton>
</div>
) : null}
{/* activity — real on-chain events */}
<div className="mt-7">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Activity</div>
<div className="flex flex-col gap-3">
{activity.length === 0 && <p className="text-sm text-neutral-600">No on-chain activity yet.</p>}
{activity.map((a: Activity, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<Avatar addr={a.actor} size={22} />
<span className="text-neutral-300">
<span className="font-mono text-neutral-200">{short(a.actor)}</span> {VERB[a.kind]}
</span>
<span className="ml-auto text-xs text-neutral-600" title={absDate(a.ts)}>{a.ts ? absDate(a.ts) : ''}</span>
</div>
))}
{/* applicant proposals / comments (functional; swappable to IAM/on-chain) */}
{comments.map((c) => (
<div key={c.id} className="rounded-lg bg-white/4 p-3">
<div className="flex items-center gap-2 text-sm">
<Avatar addr={`0x${c.author}`} size={20} />
<span className="font-medium text-neutral-200">{c.author}</span>
<span className="text-xs text-neutral-600">{timeAgo(c.ts)}</span>
</div>
<p className="mt-1.5 text-sm text-neutral-300">{c.body}</p>
</div>
))}
<div className="mt-1 flex items-center gap-2">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addComment()}
placeholder="Add a comment or application…"
className="flex-1 rounded-md bg-white/5 px-3 py-2 text-sm text-neutral-200 placeholder:text-neutral-600 ring-1 ring-inset ring-white/8 focus:outline-none"
/>
<PrimaryButton onClick={addComment}>Send</PrimaryButton>
</div>
</div>
</div>
</div>
{/* right column */}
<div className="flex flex-col gap-5">
<Field label="Status">
<span className="inline-flex items-center gap-1.5 text-sm text-neutral-200">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: 'var(--brand)' }} />
{STATE_TITLE[task.state as State]}
</span>
</Field>
<Field label="Assignee">
{assigned ? (
<span className="flex items-center gap-2 text-sm text-neutral-200">
<Avatar addr={task.worker} size={22} /> <span className="font-mono">{short(task.worker)}</span>
</span>
) : (
<span className="text-sm text-neutral-500">No task assignee…</span>
)}
</Field>
{reviewer && (
<Field label="Reviewers">
<span className="flex items-center gap-2 text-sm text-neutral-200">
<Avatar addr={reviewer} size={22} /> <span className="font-mono">{short(reviewer)}</span>
</span>
</Field>
)}
{task.reputation.karma !== undefined && assigned && (
<Field label="Global Karma">
<span className="text-sm text-fuchsia-300" title="Portable soul-bound Karma across every DAO">
{formatAmount(task.reputation.karma)} K
</span>
</Field>
)}
</div>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { ORG } from '../config';
import { PrimaryButton, GhostButton, IconSort, IconFilter, IconSearch } from '../ui';
// Connect / Follow — top-right on every content view. Connect points at the org's
// Hanzo IAM OIDC entry (wired by the CTO agent); until then it falls back to the
// org's Discord so the button is never dead.
export function Topbar() {
const connectHref = ORG.iam?.authUrl ?? ORG.social.discord ?? ORG.social.website ?? '#';
return (
<div className="absolute right-6 top-5 z-10 flex flex-col items-end gap-2">
<a href={connectHref} target="_blank" rel="noreferrer noopener">
<PrimaryButton>Connect</PrimaryButton>
</a>
<GhostButton>★ Follow</GhostButton>
</div>
);
}
export type TabDef = { key: string; label: string; icon: ReactNode };
export function Tabs({ tabs, active, onSelect }: { tabs: TabDef[]; active: string; onSelect: (k: string) => void }) {
return (
<div className="flex items-center gap-1">
{tabs.map((t) => {
const on = t.key === active;
return (
<button
key={t.key}
onClick={() => onSelect(t.key)}
className={`relative flex items-center gap-1.5 px-2.5 py-2 text-sm transition-colors ${
on ? 'font-medium text-neutral-100' : 'text-neutral-400 hover:text-neutral-200'
}`}
>
<span className={on ? 'text-neutral-200' : 'text-neutral-500'}>{t.icon}</span>
{t.label}
{on && <span className="absolute inset-x-1 -bottom-px h-0.5 rounded-full" style={{ backgroundColor: 'var(--brand)' }} />}
</button>
);
})}
</div>
);
}
// Sort / Filter / Search — the board & list toolbar. Search filters by title;
// the value is lifted so a view can apply it (real, not decorative).
export function Toolbar({ query, onQuery }: { query: string; onQuery: (q: string) => void }) {
const [openHint, setOpenHint] = useState(false);
return (
<div className="flex items-center gap-2 text-sm text-neutral-400">
<button className="flex items-center gap-1.5 rounded px-2 py-1 hover:bg-white/5" onClick={() => setOpenHint((v) => !v)}>
<IconSort className="h-4 w-4" /> Sort
</button>
<button className="flex items-center gap-1.5 rounded px-2 py-1 hover:bg-white/5" onClick={() => setOpenHint((v) => !v)}>
<IconFilter className="h-4 w-4" /> Filter
</button>
<label className="flex items-center gap-1.5 rounded-md bg-white/5 px-2 py-1 ring-1 ring-inset ring-white/8">
<IconSearch className="h-3.5 w-3.5" />
<input
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder="Search tasks..."
className="w-32 bg-transparent text-neutral-200 placeholder:text-neutral-600 focus:outline-none"
/>
</label>
{openHint && <span className="text-xs text-neutral-600">sorted by reward · newest</span>}
</div>
);
}
+5
View File
@@ -5,6 +5,11 @@ import { resolveBrand, resolveBrandKey } from './brands';
const BRAND = resolveBrand(import.meta.env.VITE_BRAND);
// The full resolved brand — the Dework views read presentation (tagline, accent,
// socials, Spaces) off this. `ADDRESSES`, `CHAIN_ID` etc. below are convenience
// aliases so the on-chain layer needn't reach through ORG.
export const ORG = BRAND;
export const BRAND_KEY = resolveBrandKey(import.meta.env.VITE_BRAND);
export const CHAIN_ID = BRAND.chainId;
+69
View File
@@ -33,6 +33,75 @@ export function refToLink(ref: string): { href?: string; text: string } {
return { text: t };
}
// "$4,036.88" from a wei native amount and a native→USD rate (header stat).
export function usdFromWei(wei: bigint, usdPerNative: number): string {
const native = Number(formatEther(wei));
const usd = native * usdPerNative;
return `$${usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// "9.0 days" / "3.5 hours" / "12 min" from a duration in seconds (header stat).
export function formatDuration(sec: number): string {
if (sec <= 0) return '—';
const days = sec / 86400;
if (days >= 1) return `${days.toFixed(1)} days`;
const hours = sec / 3600;
if (hours >= 1) return `${hours.toFixed(1)} hours`;
return `${Math.max(1, Math.round(sec / 60))} min`;
}
// Absolute timestamp like "May 21, 2026 1:54 PM" (activity log).
export function absDate(unixSec: number): string {
if (!unixSec) return '';
return new Date(unixSec * 1000).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
// Relative age like "19 days ago" / "3 hours ago" from unix seconds.
export function timeAgo(unixSec: number): string {
if (!unixSec) return '';
const diff = Date.now() / 1000 - unixSec;
const steps: [number, number, string][] = [
[60, 1, 'second'],
[3600, 60, 'minute'],
[86400, 3600, 'hour'],
[2592000, 86400, 'day'],
[31536000, 2592000, 'month'],
[Number.POSITIVE_INFINITY, 31536000, 'year'],
];
for (const [limit, div, unit] of steps) {
if (Math.abs(diff) < limit) {
const n = Math.max(1, Math.floor(Math.abs(diff) / div));
return `${n} ${unit}${n === 1 ? '' : 's'} ago`;
}
}
return '';
}
// Deterministic avatar for an address: a two-stop gradient + 2-char monogram.
// Same address → same colours everywhere (identity, not decoration).
export function addrGradient(addr: string): string {
const h = hashAddr(addr);
const a = h % 360;
const b = (h * 7 + 90) % 360;
return `linear-gradient(135deg, hsl(${a} 65% 45%), hsl(${b} 60% 35%))`;
}
export function avatarText(addr: string): string {
return addr.slice(2, 4).toUpperCase();
}
function hashAddr(addr: string): number {
let h = 0;
for (let i = 2; i < addr.length; i++) h = (h * 31 + addr.charCodeAt(i)) >>> 0;
return h;
}
// Compact relative time from a unix-seconds deadline. 0 => not set => ''.
export function relTime(unixSec: bigint): string {
if (unixSec === 0n) return '';
+69
View File
@@ -0,0 +1,69 @@
import { formatUnits } from 'viem';
import type { Address } from 'viem';
import type { Bounty, Reward } from './types';
import { RewardType } from './types';
import { isZero, short } from './format';
// Resolve a bounty's on-chain reward into the reward model. Today the Bounty struct
// carries only (token, reward): native when token==0, else ERC-20. The finished
// contract adds `rewardType` + `tokenId`; when present we honour them, so NFT rewards
// render the moment that struct lands — no UI change required. "Pay in crypto OR NFTs."
export function parseReward(b: Bounty): Reward {
const t = b.rewardType;
if (t === RewardType.ERC721) return { type: RewardType.ERC721, token: b.token, tokenId: b.tokenId ?? 0n };
if (t === RewardType.ERC1155)
return { type: RewardType.ERC1155, token: b.token, tokenId: b.tokenId ?? 0n, amount: b.reward };
if (t === RewardType.ERC20 || (t === undefined && !isZero(b.token)))
return { type: RewardType.ERC20, token: b.token, amount: b.reward };
return { type: RewardType.Native, amount: b.reward };
}
// Optional NFT metadata (name + image), resolved from tokenURI/uri when it is an
// on-chain data: URI (CSP-safe). http/ipfs URIs are left to the caller.
export type NftMeta = { name?: string; image?: string; collection?: string };
export function isNft(r: Reward): boolean {
return r.type === RewardType.ERC721 || r.type === RewardType.ERC1155;
}
export type RewardDisplay = {
primary: string; // "0.0002 ZOO" | "Bored Ape #42" | "5× Sword #7"
secondary?: string; // token address (short) for ERC-20/NFT
image?: string; // NFT image, when metadata resolved
isNft: boolean;
};
// Render a reward for a card / payout line. `nativeSymbol` is the org's coin;
// `tokenLabel` maps an ERC-20 address to a symbol; `meta` is resolved NFT metadata.
export function formatReward(
r: Reward,
nativeSymbol: string,
tokenLabel: (t: Address) => string,
meta?: NftMeta,
): RewardDisplay {
switch (r.type) {
case RewardType.Native:
return { primary: `${trim(formatUnits(r.amount, 18))} ${nativeSymbol}`, isNft: false };
case RewardType.ERC20:
return {
primary: `${trim(formatUnits(r.amount, 18))} ${tokenLabel(r.token)}`,
secondary: short(r.token),
isNft: false,
};
case RewardType.ERC721: {
const name = meta?.name ?? `${meta?.collection ?? short(r.token)} #${r.tokenId}`;
return { primary: name, secondary: short(r.token), image: meta?.image, isNft: true };
}
case RewardType.ERC1155: {
const label = meta?.name ?? `${meta?.collection ?? short(r.token)} #${r.tokenId}`;
const qty = r.amount > 1n ? `${r.amount}× ` : '';
return { primary: `${qty}${label}`, secondary: short(r.token), image: meta?.image, isNft: true };
}
}
}
// Trim trailing zeros from a formatted decimal ("0.000200" -> "0.0002", "1.0" -> "1").
function trim(s: string): string {
if (!s.includes('.')) return s;
return s.replace(/\.?0+$/, '');
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
// A tiny hash router — no dependency. The board is a static SPA served with an
// index fallback, so hash routing is the one obvious way: it needs no server
// rewrites and is CSP-safe. Task detail opens as a modal via a `?task=<id>` query
// on any route, so the underlying view stays put (matches Dework).
export type View = 'overview' | 'board' | 'leaderboards' | 'suggestions' | 'explore' | 'space';
export type Tab = 'overview' | 'board' | 'tasks' | 'suggestions';
export type Route = {
view: View;
spaceKey?: string;
tab: Tab;
taskId?: number; // modal overlay, orthogonal to `view`
};
function parse(hash: string): Route {
const raw = hash.replace(/^#\/?/, '');
const [path, queryStr] = raw.split('?');
const seg = path.split('/').filter(Boolean);
const query = new URLSearchParams(queryStr ?? '');
const taskRaw = query.get('task');
const taskId = taskRaw !== null && /^\d+$/.test(taskRaw) ? Number(taskRaw) : undefined;
if (seg[0] === 'space' && seg[1]) {
const tab = (['overview', 'board', 'tasks', 'suggestions'] as const).find((t) => t === seg[2]) ?? 'board';
return { view: 'space', spaceKey: seg[1], tab, taskId };
}
const top = seg[0] as View | undefined;
if (top === 'board') return { view: 'board', tab: 'board', taskId };
if (top === 'leaderboards') return { view: 'leaderboards', tab: 'overview', taskId };
if (top === 'suggestions') return { view: 'suggestions', tab: 'suggestions', taskId };
if (top === 'explore') return { view: 'explore', tab: 'overview', taskId };
return { view: 'overview', tab: 'overview', taskId };
}
export function useRoute(): Route {
const [route, setRoute] = useState<Route>(() =>
parse(typeof window === 'undefined' ? '' : window.location.hash),
);
useEffect(() => {
const onChange = () => setRoute(parse(window.location.hash));
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, []);
return route;
}
// Navigate by setting the hash. `to` is the path without the leading '#'.
export function navigate(to: string): void {
const next = to.startsWith('#') ? to : `#${to.startsWith('/') ? '' : '/'}${to}`;
if (window.location.hash !== next) window.location.hash = next;
}
// Open / close the task-detail modal while preserving the current view.
export function openTask(id: number): void {
const base = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
navigate(`${base}?task=${id}`);
}
export function closeTask(): void {
const base = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
navigate(base);
}
+29
View File
@@ -0,0 +1,29 @@
// Skill taxonomy — the Dework filter chips / task tags. Shared across every org
// (not brand-varying). Each skill has an emoji + a literal Tailwind pill class
// (written out so Tailwind's content scanner emits it — never build dynamically).
export type SkillMeta = { emoji: string; cls: string };
export const SKILL_META: Record<string, SkillMeta> = {
Development: { emoji: '💻', cls: 'bg-blue-500/10 text-blue-300 ring-blue-500/25' },
Design: { emoji: '🎨', cls: 'bg-pink-500/10 text-pink-300 ring-pink-500/25' },
Translation: { emoji: '🌐', cls: 'bg-cyan-500/10 text-cyan-300 ring-cyan-500/25' },
Writing: { emoji: '✍️', cls: 'bg-violet-500/10 text-violet-300 ring-violet-500/25' },
Marketing: { emoji: '📣', cls: 'bg-orange-500/10 text-orange-300 ring-orange-500/25' },
Community: { emoji: '🧑‍🤝‍🧑', cls: 'bg-teal-500/10 text-teal-300 ring-teal-500/25' },
Product: { emoji: '📦', cls: 'bg-indigo-500/10 text-indigo-300 ring-indigo-500/25' },
Research: { emoji: '🔬', cls: 'bg-sky-500/10 text-sky-300 ring-sky-500/25' },
Legal: { emoji: '⚖️', cls: 'bg-amber-500/10 text-amber-300 ring-amber-500/25' },
Operations: { emoji: '🛠️', cls: 'bg-lime-500/10 text-lime-300 ring-lime-500/25' },
Admin: { emoji: '🗂️', cls: 'bg-slate-500/10 text-slate-300 ring-slate-500/25' },
'Data Analytics': { emoji: '📊', cls: 'bg-emerald-500/10 text-emerald-300 ring-emerald-500/25' },
};
// The chip order for the skills filter and the About taxonomy.
export const SKILLS = Object.keys(SKILL_META);
export const LANGUAGES = ['English', 'Chinese', 'Spanish', 'Korean', 'Japanese', 'Russian'];
export function skillMeta(skill: string): SkillMeta {
return SKILL_META[skill] ?? { emoji: '🏷️', cls: 'bg-neutral-500/10 text-neutral-300 ring-neutral-500/25' };
}
+80
View File
@@ -0,0 +1,80 @@
import type { Space } from './brands';
import type { BountyView, Task } from './types';
import { laneOf, openToOf } from './types';
import { parseReward } from './reward';
// Enrich on-chain bounties into Dework Tasks: resolve the Space (bounty category),
// a human title, skills and the reward model. The Space assignment is config-driven
// (Bounty has no on-chain category) — a bounty routes to the first Space whose
// `match` prefix its issueRef starts with, else the catch-all Space.
export function defaultSpaceKey(spaces: Space[]): string {
const catchAll = spaces.find((s) => !s.match || s.match.length === 0);
return (catchAll ?? spaces[0])?.key ?? 'general';
}
export function resolveSpaceKey(issueRef: string | undefined, spaces: Space[]): string {
const ref = (issueRef ?? '').toLowerCase();
if (ref) {
for (const s of spaces) {
if (s.match && s.match.some((p) => ref.startsWith(p.toLowerCase()))) return s.key;
}
}
return defaultSpaceKey(spaces);
}
// A readable title from the issueRef. GitHub refs → "repo #n"; URLs → last path
// segment; a bare slug → itself; nothing → "Task #id".
export function deriveTitle(issueRef: string | undefined, id: number): string {
const ref = (issueRef ?? '').trim();
if (!ref) return `Task #${id}`;
const gh = ref.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/);
if (gh) return `${gh[2]} #${gh[3]}`;
if (/^https?:\/\//i.test(ref)) {
const tail = ref.replace(/\/+$/, '').split('/').pop();
return tail || ref;
}
return ref.charAt(0).toUpperCase() + ref.slice(1);
}
export function enrichTask(b: BountyView, spaces: Space[]): Task {
const spaceKey = resolveSpaceKey(b.issueRef, spaces);
const space = spaces.find((s) => s.key === spaceKey);
return {
...b,
title: deriveTitle(b.issueRef, b.id),
spaceKey,
skills: space?.skills ?? [],
lane: laneOf(b.state),
openTo: openToOf(b.state),
parsedReward: parseReward(b),
};
}
export function enrichTasks(bounties: BountyView[], spaces: Space[]): Task[] {
return bounties.map((b) => enrichTask(b, spaces));
}
// Per-space rollups for the Overview category cards: open-task count + distinct
// contributors (workers who have touched a task in that space).
export type SpaceRollup = { open: number; contributors: number; total: number };
export function rollupBySpace(tasks: Task[], spaces: Space[]): Map<string, SpaceRollup> {
const out = new Map<string, SpaceRollup>();
for (const s of spaces) out.set(s.key, { open: 0, contributors: 0, total: 0 });
const workersBySpace = new Map<string, Set<string>>();
for (const s of spaces) workersBySpace.set(s.key, new Set());
for (const t of tasks) {
const r = out.get(t.spaceKey);
if (!r) continue;
r.total += 1;
if (t.lane === 'todo') r.open += 1;
const w = t.worker.toLowerCase();
if (w !== '0x0000000000000000000000000000000000000000') workersBySpace.get(t.spaceKey)?.add(w);
}
for (const s of spaces) {
const r = out.get(s.key);
if (r) r.contributors = workersBySpace.get(s.key)?.size ?? 0;
}
return out;
}
+34 -38
View File
@@ -1,44 +1,40 @@
import { State } from './types';
import { State, Lane } from './types';
// Per-state accent classes. Written out as literal strings so Tailwind's
// The Dework dark palette, as literal Tailwind classes. Written out so Tailwind's
// content scanner can see (and emit) every one — never build these dynamically.
// Per-state accent (badges on the task detail / list rows).
type Accent = { dot: string; badge: string };
export const ACCENT: Record<State, Accent> = {
[State.None]: {
dot: 'bg-neutral-500',
badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30',
},
[State.Open]: {
dot: 'bg-sky-400',
badge: 'bg-sky-500/10 text-sky-300 ring-1 ring-inset ring-sky-500/30',
},
[State.Funded]: {
dot: 'bg-violet-400',
badge: 'bg-violet-500/10 text-violet-300 ring-1 ring-inset ring-violet-500/30',
},
[State.Claimed]: {
dot: 'bg-amber-400',
badge: 'bg-amber-500/10 text-amber-300 ring-1 ring-inset ring-amber-500/30',
},
[State.Submitted]: {
dot: 'bg-blue-400',
badge: 'bg-blue-500/10 text-blue-300 ring-1 ring-inset ring-blue-500/30',
},
[State.Accepted]: {
dot: 'bg-teal-400',
badge: 'bg-teal-500/10 text-teal-300 ring-1 ring-inset ring-teal-500/30',
},
[State.Paid]: {
dot: 'bg-emerald-400',
badge: 'bg-emerald-500/10 text-emerald-300 ring-1 ring-inset ring-emerald-500/30',
},
[State.Disputed]: {
dot: 'bg-rose-400',
badge: 'bg-rose-500/10 text-rose-300 ring-1 ring-inset ring-rose-500/30',
},
[State.Cancelled]: {
dot: 'bg-neutral-500',
badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30',
},
[State.None]: { dot: 'bg-neutral-500', badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30' },
[State.Open]: { dot: 'bg-sky-400', badge: 'bg-sky-500/10 text-sky-300 ring-1 ring-inset ring-sky-500/30' },
[State.Funded]: { dot: 'bg-violet-400', badge: 'bg-violet-500/10 text-violet-300 ring-1 ring-inset ring-violet-500/30' },
[State.Claimed]: { dot: 'bg-amber-400', badge: 'bg-amber-500/10 text-amber-300 ring-1 ring-inset ring-amber-500/30' },
[State.Submitted]: { dot: 'bg-blue-400', badge: 'bg-blue-500/10 text-blue-300 ring-1 ring-inset ring-blue-500/30' },
[State.Accepted]: { dot: 'bg-teal-400', badge: 'bg-teal-500/10 text-teal-300 ring-1 ring-inset ring-teal-500/30' },
[State.Paid]: { dot: 'bg-emerald-400', badge: 'bg-emerald-500/10 text-emerald-300 ring-1 ring-inset ring-emerald-500/30' },
[State.Disputed]: { dot: 'bg-rose-400', badge: 'bg-rose-500/10 text-rose-300 ring-1 ring-inset ring-rose-500/30' },
[State.Cancelled]: { dot: 'bg-neutral-500', badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30' },
};
// Per-lane header accent (the coloured status ring on the board column titles).
export const LANE_ACCENT: Record<Lane, string> = {
[Lane.ToDo]: 'text-neutral-400',
[Lane.InProgress]: 'text-amber-400',
[Lane.InReview]: 'text-blue-400',
[Lane.Done]: 'text-emerald-400',
};
// Dework surface tokens (used across views for a consistent dark chrome).
export const SURFACE = {
page: 'bg-[#0f0f11]',
panel: 'bg-[#17171a]',
card: 'bg-[#1e1e22]',
cardHover: 'hover:bg-[#232327]',
ring: 'ring-1 ring-inset ring-white/8',
border: 'border-[#26262b]',
heading: 'text-neutral-100',
body: 'text-neutral-300',
muted: 'text-neutral-500',
} as const;
+122 -13
View File
@@ -1,6 +1,7 @@
import type { Address } from 'viem';
// BountyV1 lifecycle. uint8 on-chain; Accepted (5) is unused by the contract.
// Bounty lifecycle. uint8 on-chain; Accepted (5) is unused by the current contract
// but reserved by the enum.
export enum State {
None = 0,
Open = 1,
@@ -13,7 +14,9 @@ export enum State {
Cancelled = 8,
}
// The current bounties(i) struct, decoded.
// The current bounties(i) struct, decoded. `rewardType`/`tokenId` are optional:
// Bounty has neither (every reward is native/ERC-20). the finished Bounty (CTO agent) adds
// them; chain.ts populates them when present so the reward model is future-proof.
export type Bounty = {
state: number;
token: Address;
@@ -28,9 +31,13 @@ export type Bounty = {
claimNonce: bigint;
reviewWindow: bigint;
reviewDeadline: bigint;
rewardType?: number; // RewardType; undefined => infer from token (native/erc20)
tokenId?: bigint; // ERC-721/1155 token id
};
export type Reputation = { completed: bigint; earned: bigint };
// Portable reputation. `earned` is the per-DAO Reputation total; `karma` is the
// GLOBAL soul-bound Karma score (optional — only when the org has a Karma address).
export type Reputation = { completed: bigint; earned: bigint; karma?: bigint };
// A bounty assembled for the board: struct + event-only refs + worker reputation.
export type BountyView = Bounty & {
@@ -40,19 +47,61 @@ export type BountyView = Bounty & {
reputation: Reputation;
};
export type ColumnDef = { state: State; title: string };
// ---- Reward model (native / ERC-20 / ERC-721 / ERC-1155) ----
export enum RewardType {
Native = 0,
ERC20 = 1,
ERC721 = 2,
ERC1155 = 3,
}
// Board columns, left to right.
export const COLUMNS: ColumnDef[] = [
{ state: State.Open, title: 'Open' },
{ state: State.Funded, title: 'Funded' },
{ state: State.Claimed, title: 'Claimed' },
{ state: State.Submitted, title: 'In Review' },
{ state: State.Paid, title: 'Paid' },
{ state: State.Disputed, title: 'Disputed' },
{ state: State.Cancelled, title: 'Cancelled' },
export type Reward =
| { type: RewardType.Native; amount: bigint }
| { type: RewardType.ERC20; token: Address; amount: bigint }
| { type: RewardType.ERC721; token: Address; tokenId: bigint }
| { type: RewardType.ERC1155; token: Address; tokenId: bigint; amount: bigint };
// ---- Board lanes (the Dework 4-column board) ----
export enum Lane {
ToDo = 'todo',
InProgress = 'progress',
InReview = 'review',
Done = 'done',
}
export type LaneDef = {
lane: Lane;
title: string;
empty: string; // empty-state copy (matches Dework)
icon: 'invite' | 'bolt' | 'review' | 'pay';
};
// Left→right, with Dework's exact empty-state framing.
export const LANES: LaneDef[] = [
{ lane: Lane.ToDo, title: 'To Do', empty: 'Put out tasks, let contributors explore and apply', icon: 'invite' },
{ lane: Lane.InProgress, title: 'In Progress', empty: 'Keep track of contributor tasks in progress', icon: 'bolt' },
{ lane: Lane.InReview, title: 'In Review', empty: 'When a contributor is done, review the work', icon: 'review' },
{ lane: Lane.Done, title: 'Done', empty: 'Pay for completed tasks in crypto using any token', icon: 'pay' },
];
// State → lane. Disputed/Cancelled/None are off-board (surfaced in the task detail).
export function laneOf(state: number): Lane | null {
switch (state) {
case State.Open:
case State.Funded:
return Lane.ToDo;
case State.Claimed:
return Lane.InProgress;
case State.Submitted:
return Lane.InReview;
case State.Accepted:
case State.Paid:
return Lane.Done;
default:
return null;
}
}
export const STATE_TITLE: Record<State, string> = {
[State.None]: 'None',
[State.Open]: 'Open',
@@ -64,3 +113,63 @@ export const STATE_TITLE: Record<State, string> = {
[State.Disputed]: 'Disputed',
[State.Cancelled]: 'Cancelled',
};
// "Open to Bids" (a task anyone can claim by staking) vs "Open to Applications"
// (a reviewer assigns). Derived from lifecycle state; both surface as a badge.
export type OpenTo = 'bids' | 'applications' | null;
export function openToOf(state: number): OpenTo {
if (state === State.Open || state === State.Funded) return 'bids';
return null;
}
// A Task is a BountyView enriched with presentation metadata (title, space, skills,
// lane, reward, open-to). This is what every Dework view renders.
export type Task = BountyView & {
title: string;
spaceKey: string;
skills: string[];
lane: Lane | null;
openTo: OpenTo;
parsedReward: Reward;
};
// ---- Activity log (assembled from the bounty event stream, with block ts) ----
export type ActivityKind =
| 'proposed'
| 'funded'
| 'claimed'
| 'submitted'
| 'accepted'
| 'paid'
| 'disputed'
| 'resolved'
| 'cancelled'
| 'slashed'
| 'finalized';
export type Activity = {
bountyId: number;
kind: ActivityKind;
actor: Address;
ts: number; // unix seconds (block timestamp)
detail?: string; // issueRef / deliverableRef / amount summary
};
// ---- Leaderboard ----
export type Contributor = {
address: Address;
completed: bigint; // tasks done (Reputation.completedOf)
earned: bigint; // per-DAO earned (Reputation.earnedOf)
karma?: bigint; // global Karma (optional)
reviewed: number; // tasks reviewed (as approver, from events)
points: bigint; // task points (derived from completed + earned)
};
// ---- Org-level stats (Bounty events) ----
export type OrgStats = {
totalPaidWei: bigint; // Σ PaymentReleased.reward
paidCount: number;
avgTimeToPaymentSec: number; // avg(paid.ts − proposed.ts)
contributorCount: number; // distinct workers/funders/approvers
};
+183
View File
@@ -0,0 +1,183 @@
import type { CSSProperties, ReactNode } from 'react';
import { addrGradient, avatarText, tokenLabel } from './format';
import { skillMeta } from './skills';
import { formatReward } from './reward';
import type { NftMeta } from './reward';
import type { Reward } from './types';
import { NATIVE_SYMBOL } from './config';
// ---- Brand accent ----
export const accent: CSSProperties = { backgroundColor: 'var(--brand)' };
export const accentText: CSSProperties = { color: 'var(--brand)' };
// ---- Avatar (deterministic identity for an address) ----
export function Avatar({ addr, size = 24, title }: { addr: string; size?: number; title?: string }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full text-[9px] font-semibold text-white/90 ring-1 ring-inset ring-white/10"
style={{ width: size, height: size, background: addrGradient(addr), fontSize: Math.max(8, size * 0.36) }}
title={title ?? addr}
>
{avatarText(addr)}
</span>
);
}
export function AvatarStack({ addrs, max = 7, size = 26 }: { addrs: string[]; max?: number; size?: number }) {
const shown = addrs.slice(0, max);
const extra = addrs.length - shown.length;
return (
<div className="flex items-center">
{shown.map((a, i) => (
<span key={a + i} style={{ marginLeft: i === 0 ? 0 : -8, zIndex: max - i }} className="rounded-full ring-2 ring-[#17171a]">
<Avatar addr={a} size={size} />
</span>
))}
{extra > 0 && (
<span
style={{ marginLeft: -8, width: size, height: size }}
className="inline-flex items-center justify-center rounded-full bg-neutral-700 text-[10px] font-semibold text-neutral-200 ring-2 ring-[#17171a]"
>
+{extra}
</span>
)}
</div>
);
}
// ---- Skill tag (emoji + coloured pill) ----
export function SkillTag({ skill, compact = false }: { skill: string; compact?: boolean }) {
const m = skillMeta(skill);
return (
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] ring-1 ring-inset ${m.cls}`}>
<span aria-hidden>{m.emoji}</span>
{!compact && <span>{skill}</span>}
</span>
);
}
// ---- Reward badge (native / ERC-20 / NFT) ----
export function RewardBadge({ reward, meta, size = 'sm' }: { reward: Reward; meta?: NftMeta; size?: 'sm' | 'lg' }) {
const d = formatReward(reward, NATIVE_SYMBOL, tokenLabel, meta);
const big = size === 'lg';
return (
<span className={`inline-flex items-center gap-1.5 ${big ? 'text-base' : 'text-xs'}`}>
{d.isNft ? (
d.image ? (
<img src={d.image} alt="" className="h-4 w-4 rounded object-cover ring-1 ring-white/10" />
) : (
<span aria-hidden>🖼️</span>
)
) : (
<IconCoins className="h-3.5 w-3.5 text-emerald-400" />
)}
<span className={`font-semibold tabular-nums ${d.isNft ? 'text-fuchsia-200' : 'text-emerald-300'}`}>{d.primary}</span>
</span>
);
}
// ---- Open-to badge ----
export function OpenToBadge({ openTo }: { openTo: 'bids' | 'applications' | null }) {
if (!openTo) return null;
const label = openTo === 'bids' ? 'Open to Bids' : 'Open to Applications';
const cls =
openTo === 'bids'
? 'bg-sky-500/10 text-sky-300 ring-sky-500/25'
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-500/25';
return (
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] ring-1 ring-inset ${cls}`}>
<IconDollar className="h-3 w-3" />
{label}
</span>
);
}
// ---- Buttons ----
export function PrimaryButton({ children, onClick, className = '' }: { children: ReactNode; onClick?: () => void; className?: string }) {
return (
<button
onClick={onClick}
style={accent}
className={`inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-white shadow-sm transition-opacity hover:opacity-90 ${className}`}
>
{children}
</button>
);
}
export function GhostButton({ children, onClick, className = '' }: { children: ReactNode; onClick?: () => void; className?: string }) {
return (
<button
onClick={onClick}
className={`inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-neutral-200 ring-1 ring-inset ring-white/12 transition-colors hover:bg-white/5 ${className}`}
>
{children}
</button>
);
}
// ---- Stat pill (header: Time to payment / Total paid) ----
export function StatPill({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-white/5 px-2.5 py-1 text-xs text-neutral-300 ring-1 ring-inset ring-white/8">
<span className="text-neutral-400">{icon}</span>
{children}
</span>
);
}
// ---- Icons (inline SVG, stroke=currentColor) ----
type IconProps = { className?: string };
const S = 'none';
function svg(path: ReactNode, vb = '0 0 24 24') {
return function Icon({ className = 'h-4 w-4' }: IconProps) {
return (
<svg viewBox={vb} fill={S} stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
{path}
</svg>
);
};
}
export const IconHome = svg(<path d="M3 11.5 12 4l9 7.5M5 10v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9" />);
export const IconPlus = svg(<path d="M12 5v14M5 12h14" />);
export const IconGrid = svg(<><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>);
export const IconBulb = svg(<><path d="M9 18h6M10 21h4" /><path d="M12 3a6 6 0 0 0-4 10.5c.7.7 1 1.3 1 2.5h6c0-1.2.3-1.8 1-2.5A6 6 0 0 0 12 3Z" /></>);
export const IconTrophy = svg(<><path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" /><path d="M7 6H4v2a3 3 0 0 0 3 3M17 6h3v2a3 3 0 0 1-3 3M9 21h6M10 17v4M14 17v4" /></>);
export const IconBoard = svg(<><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16M15 4v16" /></>);
export const IconList = svg(<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />);
export const IconClock = svg(<><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>);
export const IconDollar = svg(<><path d="M12 2v20M17 6.5C17 4.6 14.8 3.5 12 3.5S7 4.6 7 6.5 9.2 9.5 12 9.5s5 1.1 5 3-2.2 3-5 3-5-1.1-5-3" /></>);
export const IconLink = svg(<><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></>);
export const IconSearch = svg(<><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>);
export const IconSort = svg(<path d="M7 4v16M7 20l-3-3M7 4l3 3M17 20V4M17 4l-3 3M17 4l3 3" />);
export const IconFilter = svg(<path d="M4 5h16l-6 7v6l-4 2v-8L4 5Z" />);
export const IconCheckCircle = svg(<><circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.5 2.5 4.5-5" /></>);
export const IconCircle = svg(<circle cx="12" cy="12" r="8.5" />);
export const IconHalfCircle = svg(<><circle cx="12" cy="12" r="8.5" /><path d="M12 3.5a8.5 8.5 0 0 1 0 17Z" fill="currentColor" stroke="none" /></>);
export const IconLock = svg(<><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></>);
export const IconBookmark = svg(<path d="M6 4h12v16l-6-4-6 4V4Z" />);
export const IconChevronDown = svg(<path d="m6 9 6 6 6-6" />);
export const IconChevronRight = svg(<path d="m9 6 6 6-6 6" />);
export const IconChevronsLeft = svg(<path d="m11 7-5 5 5 5M18 7l-5 5 5 5" />);
export const IconInfo = svg(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
export const IconX = svg(<path d="M6 6l12 12M18 6 6 18" />);
export const IconDots = svg(<path d="M5 12h.01M12 12h.01M19 12h.01" />);
export const IconExternal = svg(<><path d="M14 4h6v6" /><path d="M20 4 10 14" /><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5" /></>);
export const IconQuestion = svg(<><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7M12 17h.01" /></>);
export const IconMail = svg(<><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" /></>);
export const IconDoc = svg(<><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" /><path d="M14 3v4h4" /></>);
export const IconCoins = svg(<><ellipse cx="9" cy="7" rx="6" ry="3" /><path d="M3 7v5c0 1.7 2.7 3 6 3s6-1.3 6-3" /><path d="M15 11c2.8.2 6 1.4 6 3.5S17.4 18 15 18M9 15v2c0 1.7 2.7 3 6 3" /></>);
export const IconUsersPlus = svg(<><circle cx="9" cy="8" r="3.2" /><path d="M3.5 20c0-3 2.5-5 5.5-5s5.5 2 5.5 5M18 8v6M15 11h6" /></>);
export const IconBolt = svg(<path d="M13 3 5 14h6l-1 7 8-11h-6l1-7Z" />);
export const IconShield = svg(<><path d="M12 3 5 6v5c0 4.5 3 8 7 9 4-1 7-4.5 7-9V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></>);
export const IconDiscord = ({ className = 'h-4 w-4' }: IconProps) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
<path d="M20 5.3A18 18 0 0 0 15.6 4l-.3.5A13 13 0 0 1 12 4a13 13 0 0 1-3.3.5L8.4 4A18 18 0 0 0 4 5.3C1.7 8.7 1.1 12 1.4 15.3A18 18 0 0 0 6.9 18l.7-1c-.6-.2-1.2-.5-1.7-.9l.4-.3a12.7 12.7 0 0 0 11.4 0l.4.3c-.5.4-1.1.7-1.7.9l.7 1a18 18 0 0 0 5.5-2.7c.4-3.8-.6-7.1-2.7-10ZM8.7 13.5c-.9 0-1.6-.8-1.6-1.8s.7-1.8 1.6-1.8 1.6.8 1.6 1.8-.7 1.8-1.6 1.8Zm6.6 0c-.9 0-1.6-.8-1.6-1.8s.7-1.8 1.6-1.8 1.6.8 1.6 1.8-.7 1.8-1.6 1.8Z" />
</svg>
);
export const IconTwitter = ({ className = 'h-4 w-4' }: IconProps) => (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
<path d="M17.5 3h3l-6.6 7.5L21.7 21h-6l-4.7-6.1L5.6 21H2.5l7-8L2 3h6.2l4.3 5.6L17.5 3Zm-2.1 16h1.7L8.7 4.8H6.9L15.4 19Z" />
</svg>
);
-56
View File
@@ -1,56 +0,0 @@
import { useEffect, useState } from 'react';
import { REFRESH_MS } from './config';
import { loadBoard } from './chain';
import { FIXTURE } from './fixture';
import type { BountyView } from './types';
export type Source = 'live' | 'fixture';
export type BoardState = {
data: BountyView[] | null;
loading: boolean;
error: string | null;
source: Source | null;
updatedAt: number | null;
};
// Polls the live board every REFRESH_MS. On failure: keep the last good live
// data if we had it (stale), otherwise fall back to the fixture so the UI renders.
export function useBounties(): BoardState {
const [state, setState] = useState<BoardState>({
data: null,
loading: true,
error: null,
source: null,
updatedAt: null,
});
useEffect(() => {
let alive = true;
async function tick() {
try {
const data = await loadBoard();
if (!alive) return;
setState({ data, loading: false, error: null, source: 'live', updatedAt: Date.now() });
} catch (err) {
if (!alive) return;
const message = err instanceof Error ? err.message : String(err);
setState((prev) =>
prev.source === 'live' && prev.data
? { ...prev, loading: false, error: message }
: { data: FIXTURE, loading: false, error: message, source: 'fixture', updatedAt: Date.now() },
);
}
}
void tick();
const timer = setInterval(() => void tick(), REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, []);
return state;
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { REFRESH_MS, ORG } from './config';
import { loadWorkspace } from './chain';
import type { Workspace } from './chain';
import { FIXTURE } from './fixture';
import { enrichTasks } from './tasks';
import { isZero } from './format';
import { State } from './types';
import type { Contributor, Task } from './types';
export type Source = 'live' | 'fixture';
export type WorkspaceState = {
ws: Workspace | null;
tasks: Task[];
loading: boolean;
error: string | null;
source: Source | null;
updatedAt: number | null;
};
// Build a workspace from bounty structs alone (no event stream) — the offline
// fixture path. Stats that need block timestamps (time-to-payment, activity) are
// left empty rather than faked.
function workspaceFromBounties(bounties: typeof FIXTURE): Workspace {
let totalPaidWei = 0n;
let paidCount = 0;
const people = new Set<string>();
const workers: Contributor[] = [];
const seen = new Set<string>();
for (const b of bounties) {
if (b.state === State.Paid) {
totalPaidWei += b.reward;
paidCount += 1;
}
for (const a of [b.worker, b.funder, b.approver]) if (!isZero(a)) people.add(a.toLowerCase());
if (!isZero(b.worker) && !seen.has(b.worker.toLowerCase())) {
seen.add(b.worker.toLowerCase());
workers.push({
address: b.worker,
completed: b.reputation.completed,
earned: b.reputation.earned,
karma: b.reputation.karma,
reviewed: 0,
points: b.reputation.completed * 100n,
});
}
}
return {
bounties,
activity: [],
stats: { totalPaidWei, paidCount, avgTimeToPaymentSec: 0, contributorCount: people.size },
contributors: workers,
};
}
// Polls the whole work-market every REFRESH_MS. On failure keep the last good live
// data (stale) if we had it, otherwise fall back to the fixture so the UI renders.
export function useWorkspace(): WorkspaceState {
const [state, setState] = useState<WorkspaceState>({
ws: null,
tasks: [],
loading: true,
error: null,
source: null,
updatedAt: null,
});
useEffect(() => {
let alive = true;
async function tick() {
try {
const ws = await loadWorkspace();
if (!alive) return;
setState({
ws,
tasks: enrichTasks(ws.bounties, ORG.spaces),
loading: false,
error: null,
source: 'live',
updatedAt: Date.now(),
});
} catch (err) {
if (!alive) return;
const message = err instanceof Error ? err.message : String(err);
setState((prev) =>
prev.source === 'live' && prev.ws
? { ...prev, loading: false, error: message }
: {
ws: workspaceFromBounties(FIXTURE),
tasks: enrichTasks(FIXTURE, ORG.spaces),
loading: false,
error: message,
source: 'fixture',
updatedAt: Date.now(),
},
);
}
}
void tick();
const timer = setInterval(() => void tick(), REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, []);
return state;
}