Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CTA } from "@/components/cta";
import { Footer } from "@/components/footer";
import { Testimonial } from "@/components/testimonial";
import { Faq } from "@/components/faq";
import { FundedWork } from "@/components/funded-work";

export default function Home() {
return (
Expand All @@ -14,6 +15,7 @@ export default function Home() {
<Hero />
<LogoList />
<LookingFor />
<FundedWork />
<Testimonial />
<CTA />
<Faq />
Expand Down
101 changes: 101 additions & 0 deletions components/funded-work.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Link from "next/link";
import { ArrowUpRight, CircleCheck, Eye, ShieldCheck } from "lucide-react";

import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getFundedWork, type FundedWorkItem } from "@/lib/funded-work";

function formatAmount(amount: number, symbol: string) {
return `${new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
}).format(amount)} ${symbol}`;
}

function formatDeadline(deadline?: string) {
if (!deadline) return "No deadline listed";
const date = new Date(deadline);
if (Number.isNaN(date.getTime())) return "Deadline listed in task";
return `Due ${new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date)}`;
}

function WorkCard({ item }: { item: FundedWorkItem }) {
return (
<Card className="group flex h-full flex-col border-foreground/10 bg-background/70 transition-colors hover:border-primary/50">
<CardHeader className="gap-3 pb-4">
<div className="flex items-start justify-between gap-3">
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" variant="outline">
<CircleCheck className="size-3.5" aria-hidden="true" />
Escrow observed
</Badge>
<span className="text-right text-sm font-semibold text-primary">
{formatAmount(item.amount, item.symbol)}
</span>
</div>
<CardTitle className="text-xl leading-tight">{item.title}</CardTitle>
<CardDescription>
{item.username ? `Posted by @${item.username} · ` : ""}
{formatDeadline(item.deadline)}
</CardDescription>
</CardHeader>
<CardContent className="mt-auto flex flex-col gap-4 pt-0">
<div className="flex items-center gap-2 rounded-md bg-muted/60 px-3 py-2 text-xs text-muted-foreground">
<ShieldCheck className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
<span>
{formatAmount(item.escrowBalance, item.symbol)} visible in the read-only funding check.
</span>
</div>
<p className="text-xs text-muted-foreground">
Funding is an observation, not a promise of award. Review eligibility and submission rules.
</p>
<Link
href={`https://app.gib.work/tasks/${item.id}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-semibold text-primary hover:underline"
>
Inspect task <ArrowUpRight className="size-4" aria-hidden="true" />
</Link>
</CardContent>
</Card>
);
}

export async function FundedWork() {
const items = await getFundedWork();

return (
<section className="relative mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 sm:py-24">
<div className="mb-8 flex flex-col items-center text-center">
<Badge variant="secondary" className="gap-2">
<Eye className="size-3.5" aria-hidden="true" />
Public funding check
</Badge>
<h2 className="mt-4 text-3xl font-semibold sm:text-4xl">
Find work with funding you can inspect
</h2>
<p className="mt-2 max-w-2xl text-muted-foreground">
Live cards read Gibwork&apos;s public task feed and read-only vault endpoint.
A green badge means a positive balance was observed when this page rendered.
</p>
</div>

{items.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{items.map((item) => <WorkCard key={item.id} item={item} />)}
</div>
) : (
<Card className="mx-auto max-w-2xl border-dashed bg-background/60">
<CardContent className="flex items-center gap-3 p-6 text-sm text-muted-foreground">
<Eye className="size-5 shrink-0" aria-hidden="true" />
No positive funding observations are available right now. Check the task feed directly for the latest work.
</CardContent>
</Card>
)}
</section>
);
}
129 changes: 129 additions & 0 deletions lib/funded-work.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const GIBWORK_API = "https://api.gib.work";

type ExploreResponse = {
results?: ExploreTask[];
};

type ExploreTask = {
id?: string;
title?: string;
deadline?: string;
isOpen?: boolean;
remainingAmount?: number | string;
asset?: {
symbol?: string;
price?: number | string;
};
tags?: string[];
user?: {
username?: string;
};
};

type VaultResponse = {
balance?: number | string;
totalDeposited?: number | string;
totalWithdrawn?: number | string;
isAccountClosed?: boolean;
};

export type FundedWorkItem = {
id: string;
title: string;
amount: number;
symbol: string;
deadline?: string;
username?: string;
escrowBalance: number;
isAccountClosed: boolean;
};

function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}

async function getJson<T>(url: string, init?: RequestInit): Promise<T | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: {
accept: "application/json",
...(init?.headers ?? {}),
},
});

if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}

/**
* Read-only funding check for the landing page. The vault endpoint is treated
* as an observation, never as a guarantee that a participant will be paid.
*/
export async function getFundedWork(): Promise<FundedWorkItem[]> {
const payload = await getJson<ExploreResponse>(
`${GIBWORK_API}/explore?page=1&limit=50`,
{ next: { revalidate: 300 } },
);

const candidates = (payload?.results ?? [])
.filter(
(task) =>
task.id &&
task.title &&
task.isOpen &&
// Keep the public strip focused on build work; it must not encourage
// visitors to use someone else's social accounts.
task.tags?.some((tag) => ["Development", "JavaScript", "TypeScript", "Rust", "Solana"].includes(tag)),
)
.map((task) => ({
task,
amount: toNumber(task.remainingAmount),
}))
.filter(
(candidate): candidate is { task: ExploreTask & { id: string; title: string }; amount: number } =>
candidate.amount !== null && candidate.amount > 0,
)
.slice(0, 6);

const checked: Array<FundedWorkItem | null> = await Promise.all(
candidates.map(async ({ task, amount }) => {
const vault = await getJson<VaultResponse>(`${GIBWORK_API}/vaults`, {
method: "POST",
body: JSON.stringify({ id: task.id, type: "task" }),
headers: { "content-type": "application/json" },
cache: "no-store",
});

const balance = toNumber(vault?.balance);
if (balance === null || balance <= 0) return null;

return {
id: task.id,
title: task.title,
amount,
symbol: task.asset?.symbol ?? "USDC",
deadline: task.deadline,
username: task.user?.username,
escrowBalance: balance,
isAccountClosed: vault?.isAccountClosed === true,
} satisfies FundedWorkItem;
}),
);

return checked.filter((item) => item !== null) as FundedWorkItem[];
}