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
7 changes: 5 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import { CTA } from "@/components/cta";
import { Footer } from "@/components/footer";
import { Testimonial } from "@/components/testimonial";
import { Faq } from "@/components/faq";
import { getFeaturedWork } from "@/lib/gibwork-api";

export default async function Home() {
const featuredWork = await getFeaturedWork();

export default function Home() {
return (
<div className="flex flex-col z-0 relative">
<Nav />
<Hero />
<LogoList />
<LookingFor />
<LookingFor featuredWork={featuredWork} />
<Testimonial />
<CTA />
<Faq />
Expand Down
118 changes: 65 additions & 53 deletions components/looking-for.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,26 @@ import {
import Image from "next/image";
import { motion } from "framer-motion";
import { FADE_UP_ANIMATION_VARIANTS } from "@/lib/framer-variants";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import type { FeaturedWork } from "@/lib/gibwork-api";

const workDetails = [
// { image: "/tasks/image-04.png", title: "Create a FAQ list for gibwork", amount: 1, token: "sol" },
type LookingForProps = {
featuredWork: FeaturedWork[];
};

{
image: "/tasks/image-01.png",
title: "Design gibwork's new landing page",
amount: 500,
token: "usdc",
},
{
image: "/tasks/image-02.png",
title: "Create developer challenges for Zircon",
amount: 500,
token: "usdc",
},
// {
// image: "/tasks/image-04.png",
// title: "Share a link to your most used dApp",
// amount: 100,
// token: "usdc",
// },
{
image: "/tasks/image-03.png",
title: "Use slug- to share a set of links on X or Reddit",
amount: 100,
token: "usdc",
},
];
const rewardNumberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
});

export function LookingFor({ featuredWork }: LookingForProps) {
const isShowingLiveWork = featuredWork.every((work) => work.isLive);

export function LookingFor() {
return (
<section className="relative max-w-5xl mx-auto w-full py-16 sm:py-24 px-4 sm:px-6">
<section
id="opportunities"
className="relative max-w-5xl mx-auto w-full py-16 sm:py-24 px-4 sm:px-6"
>
<Tabs defaultValue="1" className="w-full flex flex-col items-center">
<motion.div
variants={FADE_UP_ANIMATION_VARIANTS}
Expand Down Expand Up @@ -172,48 +159,73 @@ export function LookingFor() {
variants={FADE_UP_ANIMATION_VARIANTS}
className="text-3xl sm:text-4xl text-center font-semibold"
>
Get paid for your expertise
{isShowingLiveWork
? "Live funded opportunities"
: "Explore funded work"}
</motion.h2>
<motion.p
variants={FADE_UP_ANIMATION_VARIANTS}
className="text-center mt-2 text-muted-foreground"
>
Discover work opportunities that you could do, complete the work,
and start earning.
{isShowingLiveWork
? "Browse open work from the marketplace, submit your best work, and get paid when it is approved."
: "See the kinds of paid opportunities you can discover on Gibwork."}
</motion.p>

<motion.div
variants={FADE_UP_ANIMATION_VARIANTS}
className="flex flex-col gap-2 mt-8 w-full"
>
{workDetails.map((_detail) => (
<Card
key={_detail.title}
className="p-4 flex items-center gap-4"
{featuredWork.map((work) => (
<Link
key={work.id}
href={work.href}
target="_blank"
rel="noreferrer"
aria-label={`View ${work.title} on Gibwork`}
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="relative aspect-square rounded-full shrink-0 w-12 bg-muted overflow-hidden">
<Image
alt=""
fill
src={_detail.image}
className="h-full w-full object-cover"
/>
</div>

<p className="font-semibold grow truncate">{_detail.title}</p>

<div className="font-semibold flex items-center justify-end gap-2 shrink-0">
<p>{_detail.amount}</p>
<div className="relative aspect-square rounded-full w-8 bg-muted overflow-hidden">
<Card className="group p-4 flex items-center gap-4 transition-colors hover:bg-muted/50">
<div className="relative aspect-square rounded-full shrink-0 w-12 bg-muted overflow-hidden">
<Image
alt=""
fill
src={`/token-${_detail.token}.png`}
src={work.imageUrl}
className="h-full w-full object-cover"
/>
</div>
</div>
</Card>

<div className="grow min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold truncate">{work.title}</p>
{work.isLive && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-emerald-700">
<span className="size-1.5 rounded-full bg-emerald-500" />
Open
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{work.isLive
? "Funded on Gibwork"
: "Example opportunity"}
</p>
</div>

<div className="font-semibold flex items-center justify-end gap-2 shrink-0">
<p>{rewardNumberFormatter.format(work.amount)}</p>
<div className="relative aspect-square rounded-full w-8 bg-muted overflow-hidden">
<Image
alt={`${work.token} reward`}
fill
src={work.tokenImageUrl}
className="h-full w-full object-cover"
/>
</div>
<ArrowUpRight className="size-4 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</div>
</Card>
</Link>
))}
</motion.div>
</motion.div>
Expand Down
135 changes: 135 additions & 0 deletions lib/gibwork-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { siteConfig } from "@/lib/site-config";

export type FeaturedWork = {
id: string;
title: string;
amount: number;
token: string;
imageUrl: string;
tokenImageUrl: string;
href: string;
isLive: boolean;
};

const fallbackWork: FeaturedWork[] = [
{
id: "landing-page",
title: "Design gibwork's new landing page",
amount: 500,
token: "USDC",
imageUrl: "/tasks/image-01.png",
tokenImageUrl: "/token-usdc.png",
href: siteConfig.appUrl,
isLive: false,
},
{
id: "developer-challenges",
title: "Create developer challenges for Zircon",
amount: 500,
token: "USDC",
imageUrl: "/tasks/image-02.png",
tokenImageUrl: "/token-usdc.png",
href: siteConfig.appUrl,
isLive: false,
},
{
id: "share-links",
title: "Use slug- to share a set of links on X or Reddit",
amount: 100,
token: "USDC",
imageUrl: "/tasks/image-03.png",
tokenImageUrl: "/token-usdc.png",
href: siteConfig.appUrl,
isLive: false,
},
];

type JsonRecord = Record<string, unknown>;

function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isSafeImageUrl(value: string) {
if (value.startsWith("/")) return true;

try {
const { hostname, protocol } = new URL(value);
return (
protocol === "https:" &&
["cdn.gib.work", "raw.githubusercontent.com"].includes(hostname)
);
} catch {
return false;
}
}

function parseWork(value: unknown): FeaturedWork | null {
if (!isRecord(value) || !isRecord(value.asset)) return null;

const { asset } = value;
const type = value.type;
const id = value.id;
const title = value.title;
const remainingAmount = value.remainingAmount;
const token = asset.symbol;
const tokenImageUrl = asset.imageUrl;

if (
value.isOpen !== true ||
value.isHidden === true ||
(type !== "tasks" && type !== "bounties") ||
typeof id !== "string" ||
!/^[a-f0-9-]{36}$/i.test(id) ||
typeof title !== "string" ||
title.trim().length === 0 ||
typeof remainingAmount !== "number" ||
!Number.isFinite(remainingAmount) ||
remainingAmount <= 0 ||
typeof token !== "string" ||
!/^[a-z0-9._-]{1,12}$/i.test(token) ||
typeof tokenImageUrl !== "string" ||
!isSafeImageUrl(tokenImageUrl)
) {
return null;
}

return {
id,
title: title.trim(),
amount: remainingAmount,
token,
imageUrl: tokenImageUrl,
tokenImageUrl,
href: `${siteConfig.appUrl}${type}/${id}`,
isLive: true,
};
}

export async function getFeaturedWork(): Promise<FeaturedWork[]> {
try {
const response = await fetch(
"https://api.gib.work/explore?page=1&limit=15",
{
headers: { Accept: "application/json" },
next: { revalidate: 300 },
}
);

if (!response.ok) return fallbackWork;

const payload: unknown = await response.json();
if (!isRecord(payload) || !Array.isArray(payload.results)) {
return fallbackWork;
}

const liveWork = payload.results
.map(parseWork)
.filter((work): work is FeaturedWork => work !== null)
.slice(0, 3);

return liveWork.length === 3 ? liveWork : fallbackWork;
} catch {
return fallbackWork;
}
}