mirror of
https://github.com/luxfi/work-board.git
synced 2026-09-24 00:24:57 +00:00
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:
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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+$/, '');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user