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
36 changes: 36 additions & 0 deletions app/api/rewards/grants/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { createApiResponse, handleApiError } from "@/lib/api/utils";
import { RewardsService } from "@/app/services/RewardsService";
import type { GrantsResult } from "@/lib/types";

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sponsorSlug = searchParams.get("sponsorSlug");

if (!sponsorSlug) {
return NextResponse.json(
createApiResponse(null, "Sponsor slug is required"),
{
status: 400,
}
);
}

const rewardsService = new RewardsService();
const result: GrantsResult = await rewardsService.getGrants(sponsorSlug);

if (result.error) {
return NextResponse.json(createApiResponse(null, result.error), {
status: 404,
});
}

return NextResponse.json(createApiResponse(result));
} catch (error) {
console.error("Rewards (Grants) API error:", error);
return NextResponse.json(createApiResponse(null, handleApiError(error)), {
status: 500,
});
}
}
24 changes: 24 additions & 0 deletions app/api/rewards/sponsors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { createApiResponse, handleApiError } from "@/lib/api/utils";
import { RewardsService } from "@/app/services/RewardsService";
import type { SponsorsResult } from "@/lib/types";

export async function GET() {
try {
const rewardsService = new RewardsService();
const result: SponsorsResult = await rewardsService.getAllowedSponsors();

if (result.error) {
return NextResponse.json(createApiResponse(null, result.error), {
status: 404,
});
}

return NextResponse.json(createApiResponse(result));
} catch (error) {
console.error("Rewards (Sponsors) API error:", error);
return NextResponse.json(createApiResponse(null, handleApiError(error)), {
status: 500,
});
}
}
33 changes: 33 additions & 0 deletions app/rewards/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CACHE_KEYS, getQueryClient } from "@/lib/utils";
import { RewardsService } from "@/app/services/RewardsService";
import { GrantsExample } from "@/components/rewards/GrantsExample";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";

export default async function RewardsPage() {
const queryClient = getQueryClient();
const rewardsService = new RewardsService();

const defaultSponsor = "base";

// Data is prefetched in the server component to hydrate the client
const [sponsors] = await Promise.all([
queryClient.fetchQuery({
queryKey: [CACHE_KEYS.REWARDS.SPONSORS_DATA],
queryFn: () => rewardsService.getAllowedSponsors(),
}),
queryClient.fetchQuery({
queryKey: [CACHE_KEYS.REWARDS.GRANTS_DATA, defaultSponsor],
queryFn: () => rewardsService.getGrants(defaultSponsor),
}),
]);

const firstSponsor = sponsors.sponsors.find(
(sponsor) => sponsor.slug === defaultSponsor
);

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<GrantsExample defaultSelectedSponsor={firstSponsor || null} />
</HydrationBoundary>
);
}
48 changes: 48 additions & 0 deletions app/services/RewardsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { RewardsApiClient } from "@/lib/api/clients/RewardsApiClient";
import { ALLOWED_SPONSORS } from "@/lib/config/rewards";
import type { GrantsResult, SponsorsResult } from "@/lib/types";
import { env } from "@/lib/config";

export class RewardsService {
private rewardsApiClient: RewardsApiClient;

constructor() {
this.rewardsApiClient = new RewardsApiClient(env.TALENT_API_KEY);
}

async getAllowedSponsors(): Promise<SponsorsResult> {
try {
const sponsorsResponse = await this.rewardsApiClient.getSponsors();
const filteredSponsors = sponsorsResponse.sponsors.filter((sponsor) =>
ALLOWED_SPONSORS.includes(sponsor.slug)
);
return {
sponsors: filteredSponsors,
error: null,
};
} catch (error) {
console.error("Failed to get sponsors:", error);
return {
sponsors: [],
error:
error instanceof Error ? error.message : "Failed to get sponsors",
};
}
}

async getGrants(sponsorSlug: string): Promise<GrantsResult> {
try {
const grantsResponse = await this.rewardsApiClient.getGrants(sponsorSlug);
return {
grants: grantsResponse.grants,
error: null,
};
} catch (error) {
console.error("Failed to get grants:", error);
return {
grants: [],
error: error instanceof Error ? error.message : "Failed to get grants",
};
}
}
}
1 change: 1 addition & 0 deletions app/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./types";
export * from "./UserService";
export * from "./AuthService";
export * from "./RewardsService";
31 changes: 0 additions & 31 deletions components/FarcasterSDKInitializer.tsx

This file was deleted.

14 changes: 14 additions & 0 deletions components/rewards/GrantRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Grant } from "@/lib/types";

export function GrantRow({ grant }: { grant: Grant }) {
return (
<div key={grant.id}>
<div>
{grant.sponsor.name} - ID: {grant.id}
</div>
<div>
{grant.start_date}, {grant.end_date}
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions components/rewards/GrantsExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { SponsorSelect } from "@/components/rewards/SponsorSelect";
import { useGrantsData } from "@/hooks/api/useRewardsData";
import { useState } from "react";
import { Sponsor } from "@/lib/types";
import { GrantRow } from "@/components/rewards/GrantRow";

interface GrantsExampleProps {
defaultSelectedSponsor: Sponsor | null;
}

export function GrantsExample({ defaultSelectedSponsor }: GrantsExampleProps) {
const [selectedSponsor, setSelectedSponsor] = useState<Sponsor | null>(
defaultSelectedSponsor
);

// Data is loaded from the server if it exists in the cache,
// otherwise it's loaded again, this time from the API
const { data: grants } = useGrantsData(selectedSponsor?.slug || "");

return (
<div>
<SponsorSelect
selectedSponsor={selectedSponsor}
onSelect={setSelectedSponsor}
/>

<div>
{grants && grants.grants.length > 0 ? (
grants.grants.map((grant) => (
<GrantRow key={grant.id} grant={grant} />
))
) : (
<div>No grants found</div>
)}
</div>
</div>
);
}
35 changes: 35 additions & 0 deletions components/rewards/SponsorSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useSponsorsData } from "@/hooks/api/useRewardsData";
import { Sponsor } from "@/lib/types";

export function SponsorSelect({
selectedSponsor,
onSelect,
}: {
selectedSponsor: Sponsor | null;
onSelect: (sponsor: Sponsor) => void;
}) {
// Data is loaded from the server if it exists in the cache,
// otherwise it's loaded again, this time from the API
const { data: sponsors } = useSponsorsData();

return (
<select
value={selectedSponsor?.id}
onChange={(e) => {
const sponsor = sponsors?.sponsors.find(
(s) => s.id === Number(e.target.value)
);
if (sponsor) {
onSelect(sponsor);
}
}}
>
<option value="">Select a sponsor</option>
{sponsors?.sponsors.map((sponsor) => (
<option key={sponsor.id} value={sponsor.id}>
{sponsor.name}
</option>
))}
</select>
);
}
10 changes: 10 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_FARCASTER_APP_URL=
NEXT_PUBLIC_FARCASTER_APP_NAME=
NEXT_PUBLIC_DEV_MODE=false
TALENT_API_KEY=
NEYNAR_API_KEY=
NEXT_PUBLIC_PRIVY_APP_ID=
PRIVY_APP_SECRET=
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
2 changes: 1 addition & 1 deletion hooks/api/useProfileData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useQuery } from "@tanstack/react-query";
import type { TalentProfile, ApiResponse } from "@/lib/types";
import { CACHE_DURATIONS } from "@/lib/config";
import { CACHE_DURATIONS } from "@/lib/utils/cache";

export function useProfileData(identifier: string | null) {
return useQuery({
Expand Down
66 changes: 66 additions & 0 deletions hooks/api/useRewardsData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import type { ApiResponse, GrantsResult, SponsorsResult } from "@/lib/types";
import { CACHE_DURATIONS, CACHE_KEYS } from "@/lib/utils/cache";

export function useSponsorsData() {
return useQuery({
queryKey: [CACHE_KEYS.REWARDS.SPONSORS_DATA],
queryFn: async (): Promise<SponsorsResult> => {
const response = await fetch("/api/rewards/sponsors");

if (!response.ok) {
throw new Error(`Failed to fetch sponsors: ${response.statusText}`);
}

const data: ApiResponse<SponsorsResult> = await response.json();

if (data.error) {
throw new Error(data.error);
}

if (!data.data) {
throw new Error("No sponsors data received");
}

return data.data;
},
staleTime: CACHE_DURATIONS.REWARDS.SPONSORS_DATA,
retry: 2,
});
}

export function useGrantsData(sponsorSlug: string) {
return useQuery({
queryKey: [CACHE_KEYS.REWARDS.GRANTS_DATA, sponsorSlug],
queryFn: async (): Promise<GrantsResult> => {
if (!sponsorSlug) {
throw new Error("No sponsor slug provided");
}

const response = await fetch(
`/api/rewards/grants?sponsorSlug=${encodeURIComponent(sponsorSlug)}`
);

if (!response.ok) {
throw new Error(`Failed to fetch grants: ${response.statusText}`);
}

const data: ApiResponse<GrantsResult> = await response.json();

if (data.error) {
throw new Error(data.error);
}

if (!data.data) {
throw new Error("No profile data received");
}

return data.data;
},
enabled: !!sponsorSlug,
staleTime: CACHE_DURATIONS.REWARDS.GRANTS_DATA,
retry: 2,
});
}
2 changes: 1 addition & 1 deletion hooks/api/useUserResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useQuery } from "@tanstack/react-query";
import type { User, AuthProvider, ApiResponse } from "@/lib/types";
import { CACHE_DURATIONS } from "@/lib/config";
import { CACHE_DURATIONS } from "@/lib/utils/cache";

interface UseUserResolverOptions {
identifier: string | null;
Expand Down
18 changes: 18 additions & 0 deletions lib/api/clients/RewardsApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BaseApiClient } from "./BaseApiClient";
import type { SponsorsResult, GrantsResult } from "@/lib/types";

export class RewardsApiClient extends BaseApiClient {
constructor(apiKey?: string) {
super("https://api.talentprotocol.com/builder_grants", {
...(apiKey && { "X-API-KEY": `${apiKey}` }),
});
}

async getSponsors(): Promise<SponsorsResult> {
return await this.get<SponsorsResult>("/sponsors");
}

async getGrants(sponsorSlug: string): Promise<GrantsResult> {
return await this.get<GrantsResult>(`/grants?sponsor_slug=${sponsorSlug}`);
}
}
1 change: 1 addition & 0 deletions lib/api/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./BaseApiClient";
export * from "./TalentApiClient";
export * from "./RewardsApiClient";
Loading