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
55 changes: 40 additions & 15 deletions src/app/connect/ConnectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,46 @@ export function ConnectPanel() {
</p>
</div>
</div>
{address ? (
<div className="mt-4 flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 ring-1 ring-inset ring-emerald-200">
<CheckCircle2 className="h-4 w-4" />
Connected: {address.slice(0, 4)}...{address.slice(-4)} ({network})
</div>
) : (
<Button
className="mt-4 w-full"
variant="outline"
onClick={connect}
disabled={connecting}
>
{connecting ? "Connecting..." : "Connect Freighter"}
</Button>
)}

<div className="mt-4 space-y-2">
{address ? (
<div className="flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 ring-1 ring-inset ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30">
<CheckCircle2 className="h-4 w-4" />
Connected: {address.slice(0, 4)}...{address.slice(-4)} ({network})
</div>
) : (
<Button
className="w-full"
variant="outline"
onClick={connect}
disabled={connecting}
>
{connecting ? "Connecting..." : "Connect Freighter"}
</Button>
)}

{user ? (
user.stellarAddress && user.stellarAddress === address ? (
<div className="flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 ring-1 ring-inset ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30">
<CheckCircle2 className="h-4 w-4" />
Linked to your account
</div>
) : address ? (
<div className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30">
This wallet is connected locally but not linked to your GitHub account.
{user.stellarAddress ? ` Your account is currently linked to ${user.stellarAddress.slice(0, 4)}...${user.stellarAddress.slice(-4)}.` : ""}
</div>
) : user.stellarAddress ? (
<div className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30">
Your account is linked to {user.stellarAddress.slice(0, 4)}...{user.stellarAddress.slice(-4)}, but it is not currently active in Freighter.
</div>
) : null
) : address ? (
<div className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30">
Wallet connected locally. Sign in with GitHub above to link this wallet to your account for payouts.
</div>
) : null}
</div>
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
</div>
</div>
Expand Down
11 changes: 8 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,24 @@ export default function HomePage() {
<section className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label="Total paid out"
value={formatCurrency(platformStats.totalPaidOut)}
value={platformStats.totalPaidOut}
format="currency"
/>
<StatCard
label="Bounties completed"
value={platformStats.bountiesCompleted.toLocaleString()}
value={platformStats.bountiesCompleted}
format="count"
/>
<StatCard
label="Active contributors"
value={platformStats.activeContributors.toLocaleString()}
value={platformStats.activeContributors}
format="count"
/>
{/* Duration doesn't fit currency/count/percent/raw formats; keep pre-formatted string */}
<StatCard
label="Avg. payout time"
value={`${platformStats.avgPayoutTimeMinutes} min`}
format="raw"
/>
</section>

Expand Down
27 changes: 20 additions & 7 deletions src/app/reputation/[handle]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { notFound } from "next/navigation";
import { fetchReputationByUsername } from "@/lib/api";
import { fetchReputationWithStatus } from "@/lib/api";
import { mockReputationProfiles } from "@/lib/mock-data";
import { StatCard } from "@/components/ui/StatCard";
import { Badge } from "@/components/ui/Badge";
import { Avatar } from "@/components/ui/Avatar";
import { formatCurrency, formatPercent } from "@/lib/utils";
// formatCurrency/formatPercent no longer needed — StatCard handles numeric formatting

export default async function ReputationPage({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const profile = await fetchReputationByUsername(
const { profile, isLive } = await fetchReputationWithStatus(
handle,
mockReputationProfiles[handle] ?? null,
);

if (!profile) notFound();

const cardStatus = isLive ? "loaded" as const : "error" as const;

return (
<div className="mx-auto max-w-4xl px-6 py-12">
<div className="flex items-center gap-4">
Expand All @@ -43,16 +45,27 @@ export default async function ReputationPage({
<div className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label="Lifetime earnings"
value={formatCurrency(profile.lifetimeEarnings)}
value={profile.lifetimeEarnings}
format="currency"
status={cardStatus}
/>
<StatCard
label="Merged PRs"
value={profile.mergedPRs}
format="count"
status={cardStatus}
/>
<StatCard label="Merged PRs" value={String(profile.mergedPRs)} />
<StatCard
label="Completion rate"
value={formatPercent(profile.completionRate)}
value={profile.completionRate}
format="percent"
status={cardStatus}
/>
<StatCard
label="On-time delivery"
value={formatPercent(profile.onTimeDeliveryRate)}
value={profile.onTimeDeliveryRate}
format="percent"
status={cardStatus}
/>
</div>

Expand Down
62 changes: 40 additions & 22 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,29 @@ import { Avatar } from "@/components/ui/Avatar";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { NetworkBadge } from "@/components/ui/NetworkBadge";
import { useAuth } from "@/context/AuthContext";
import type { UserRole } from "@/types";

const links = [
{ href: "/issues", label: "Bounties" },
{ href: "/milestones", label: "Milestones" },
];

const dashboardLinks = [
{ href: "/dashboard/contributor", label: "Contributor" },
{ href: "/dashboard/maintainer", label: "Maintainer" },
{ href: "/dashboard/sponsor", label: "Sponsor" },
const dashboardLinks: { href: string; label: string; role: UserRole }[] = [
{ href: "/dashboard/contributor", label: "Contributor", role: "contributor" },
{ href: "/dashboard/maintainer", label: "Maintainer", role: "maintainer" },
{ href: "/dashboard/sponsor", label: "Sponsor", role: "sponsor" },
];

export function Navbar() {
const { user, loading, logout } = useAuth();

// Filter dashboard links to only those matching the user's assigned roles.
// Supports multi-role users: if roles contains both "maintainer" and "sponsor",
// both dashboard links are shown. If no roles yet (e.g. new user), show none.
const visibleDashboardLinks = user
? dashboardLinks.filter((link) => user.roles.includes(link.role))
: [];

return (
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-3.5">
Expand All @@ -38,25 +46,28 @@ export function Navbar() {
{link.label}
</Link>
))}
<div className="group relative">
<button className="flex items-center gap-1 hover:text-slate-900 dark:hover:text-white">
Dashboards
<ChevronDown className="h-3.5 w-3.5" />
</button>
<div className="invisible absolute left-0 top-full pt-3 opacity-0 transition-all group-hover:visible group-hover:opacity-100">
<div className="w-44 rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
{dashboardLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="block rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
{link.label}
</Link>
))}
{/* Only show Dashboards dropdown if there are visible role-based links */}
{visibleDashboardLinks.length > 0 && (
<div className="group relative">
<button className="flex items-center gap-1 hover:text-slate-900 dark:hover:text-white">
Dashboards
<ChevronDown className="h-3.5 w-3.5" />
</button>
<div className="invisible absolute left-0 top-full pt-3 opacity-0 transition-all group-hover:visible group-hover:opacity-100">
<div className="w-44 rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
{visibleDashboardLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="block rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
{link.label}
</Link>
))}
</div>
</div>
</div>
</div>
)}
{user && (
<Link href={`/reputation/${user.username}`} className="hover:text-slate-900 dark:hover:text-white">
Reputation
Expand All @@ -67,7 +78,14 @@ export function Navbar() {
<div className="flex items-center gap-3">
<NetworkBadge />
<ThemeToggle />
{loading ? null : user ? (
{loading ? (
/* Loading skeleton: stable placeholder matching the logged-in layout dimensions
to prevent flash of logged-out buttons before auth resolves. */
<div className="flex items-center gap-3">
<div className="h-7 w-24 animate-pulse rounded-md bg-slate-200 dark:bg-slate-800" />
<div className="h-8 w-8 animate-pulse rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
) : user ? (
<div className="flex items-center gap-3">
<Link
href={`/reputation/${user.username}`}
Expand Down
18 changes: 18 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,22 @@ export async function fetchReputationByUsername(
}
}


export async function fetchReputationWithStatus(
username: string,
fallback: ReputationProfile | null,
): Promise<{ profile: ReputationProfile | null; isLive: boolean }> {
try {
const users = await request<(RawUserProfile & { id: string })[]>("/users");
const user = users.find((u) => u.username === username);
if (!user) return { profile: fallback, isLive: false };
const snapshot = await request<RawReputationSnapshot | null>(
`/reputation/${user.id}`,
);
return { profile: adaptReputation(user, snapshot), isLive: true };
} catch {
return { profile: fallback, isLive: false };
}
}

export { request };