Skip to content
Open
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
22 changes: 22 additions & 0 deletions app/api/spark-credits/[userId]/history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { SparkCreditsService } from "@/lib/services/spark-credits";

export async function GET(
request: NextRequest,
context: { params: Promise<{ userId: string }> },
) {
const { userId } = await context.params;
const { searchParams } = request.nextUrl;
const rawLimit = Number(searchParams.get("limit") ?? "20");
const rawOffset = Number(searchParams.get("offset") ?? "0");
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(rawLimit, 1), 100)
: 20;
const offset = Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : 0;
const history = await SparkCreditsService.getCreditHistory(
userId,
limit,
offset,
);
return NextResponse.json(history);
}
11 changes: 11 additions & 0 deletions app/api/spark-credits/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { SparkCreditsService } from "@/lib/services/spark-credits";

export async function GET(
_request: NextRequest,
context: { params: Promise<{ userId: string }> },
) {
const { userId } = await context.params;
const balance = await SparkCreditsService.getBalance(userId);
return NextResponse.json(balance);
}
34 changes: 34 additions & 0 deletions app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import { useContributorReputation } from "@/hooks/use-reputation";
import { useBounties } from "@/hooks/use-bounties";
import { useSparkCreditsHistory } from "@/hooks/use-spark-credits";
import { ReputationCard } from "@/components/reputation/reputation-card";
import { CompletionHistory } from "@/components/reputation/completion-history";
import { MyClaims, type MyClaim } from "@/components/reputation/my-claims";
import { CreditHistory } from "@/components/reputation/credit-history";
import { CreditExplainer } from "@/components/reputation/credit-explainer";
import { CreditBalance } from "@/components/reputation/credit-balance";
import {
EarningsSummary,
type EarningsSummary as EarningsSummaryType,
Expand Down Expand Up @@ -38,6 +42,12 @@ export default function ProfilePage() {
isError: historyError,
} = useCompletionHistory(userId);

const {
data: creditsHistory,
isLoading: creditsHistoryLoading,
isError: creditsHistoryError,
} = useSparkCreditsHistory(userId);

const records = completionData?.records ?? [];

const myClaims = useMemo<MyClaim[]>(() => {
Expand Down Expand Up @@ -206,6 +216,12 @@ export default function ProfilePage() {
>
My Claims
</TabsTrigger>
<TabsTrigger
value="credits"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3"
>
Spark Credits
</TabsTrigger>
</TabsList>

<TabsContent value="history" className="mt-6">
Expand Down Expand Up @@ -248,6 +264,24 @@ export default function ProfilePage() {
</div>
)}
</TabsContent>
<TabsContent value="credits" className="mt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Spark Credits</h2>
<CreditBalance userId={userId} />
</div>
{creditsHistoryLoading ? (
<Skeleton className="h-48 w-full" />
) : creditsHistoryError ? (
<div className="text-center text-muted-foreground text-sm">
Unable to load credit history.
</div>
) : (
<div className="space-y-6">
<CreditHistory events={creditsHistory?.events ?? []} />
<CreditExplainer />
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions components/global-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { SearchCommand } from "@/components/search-command";
import { NavRankBadge } from "@/components/leaderboard/nav-rank-badge";
import { WalletSheet } from "@/components/wallet/wallet-sheet";
import { CreditBalance } from "@/components/reputation/credit-balance";
import { mockWalletInfo } from "@/lib/mock-wallet";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "./mode-toggle";
Expand Down Expand Up @@ -61,8 +62,8 @@ export function GlobalNavbar() {
href="/transparency"
className={`transition-colors hover:text-foreground/80 ${
pathname.startsWith("/transparency")
? "text-foreground"
: "text-foreground/60"
? "text-foreground"
: "text-foreground/60"
}`}
>
Transparency
Expand Down Expand Up @@ -92,6 +93,7 @@ export function GlobalNavbar() {

<div className="flex items-center gap-2">
<NavRankBadge userId="user-1" className="hidden sm:flex" />
<CreditBalance userId="user-1" className="hidden sm:flex" />
{/* TODO: Replace with actual auth user ID */}

<WalletSheet
Expand Down
80 changes: 80 additions & 0 deletions components/reputation/credit-balance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { useSparkCreditsBalance } from "@/hooks/use-spark-credits";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Zap } from "lucide-react";

interface CreditBalanceProps {
userId: string;
className?: string;
}

export function CreditBalance({ userId, className }: CreditBalanceProps) {
const { data, isLoading, isError } = useSparkCreditsBalance(userId);

if (isLoading) {
return <Skeleton className="h-6 w-14 rounded-full" />;
}

if (isError) {
return (
<div
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium border border-border/50 bg-secondary/10 text-muted-foreground",
className,
)}
>
<Zap className="h-3.5 w-3.5" />
<span>—</span>
</div>
);
}

const balance = data?.balance ?? 0;
const isLow = balance <= 1;

return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium border",
isLow
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border/50 bg-secondary/10 text-foreground",
className,
)}
>
<Zap className="h-3.5 w-3.5 fill-current" />
<span>{balance}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-56 text-xs">
<p className="font-semibold mb-1">Spark Credits</p>
<p className="text-muted-foreground">
Each FCFS bounty claim costs 1 Spark Credit. Earn credits by
completing bounties.
</p>
{isLow && balance === 0 && (
<p className="mt-1 text-destructive font-medium">
No credits remaining — complete a bounty to earn more.
</p>
)}
{isLow && balance === 1 && (
<p className="mt-1 text-destructive font-medium">
Only 1 credit left — consider completing a bounty soon.
</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
102 changes: 102 additions & 0 deletions components/reputation/credit-explainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Zap, Trophy, CheckCircle, AlertCircle } from "lucide-react";

interface CreditExplainerProps {
className?: string;
}

export function CreditExplainer({ className }: CreditExplainerProps) {
return (
<Card className={cn("border-border/50", className)}>
<CardHeader className="pb-3 border-b border-border/50 bg-secondary/10">
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 fill-current text-yellow-500" />
About Spark Credits
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-5">
<p className="text-sm text-muted-foreground">
Spark Credits are application credits that prevent spam while ensuring
quality contributors always have opportunities.
</p>

{/* How to earn */}
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-yellow-500" />
How to Earn Credits
</h4>
<ul className="space-y-1.5 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-green-500" />
<span>Complete a bounty — earn 1 credit per completion</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-green-500" />
<span>Win a competition bounty — earn bonus credits</span>
</li>
</ul>
</div>

{/* Costs */}
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-1.5">
<Zap className="h-4 w-4 text-yellow-500" />
Credit Costs
</h4>
<div className="rounded-lg border border-border/50 divide-y divide-border/50">
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span className="text-muted-foreground">Claim a FCFS bounty</span>
<span className="flex items-center gap-1 font-medium">
<Zap className="h-3.5 w-3.5 fill-current text-yellow-500" />1
credit
</span>
</div>
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span className="text-muted-foreground">Enter a competition</span>
<span className="text-muted-foreground text-xs italic">Free</span>
</div>
</div>
</div>

{/* Tips */}
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-1.5">
<AlertCircle className="h-4 w-4 text-blue-500" />
Tips
</h4>
<ul className="space-y-1.5 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<span className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-center text-xs font-bold text-blue-500">
</span>
<span>
Complete bounties before claiming new ones to keep your credit
balance healthy.
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-center text-xs font-bold text-blue-500">
</span>
<span>
Your credit balance is shown in the navbar so you always know
your current standing.
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-center text-xs font-bold text-blue-500">
</span>
<span>
Credits cannot be purchased — they can only be earned through
contributions.
</span>
</li>
</ul>
</div>
</CardContent>
</Card>
);
}
Loading
Loading