Skip to content
45 changes: 26 additions & 19 deletions app/(landing)/profile/[username]/profile-data.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
'use client';

import { useEffect, useState } from 'react';
import { Filter } from 'lucide-react';
import { getUserProfileByUsername } from '@/lib/api/auth';
import { authClient } from '@/lib/auth-client';
import { PublicUserProfile } from '@/features/projects/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import ActivityTab from '@/components/profile/ActivityTab';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import ActivityTab from '@/components/profile/ActivityTab';
import OrganizationsTab from '@/components/profile/OrganizationsTab';
import { Filter } from 'lucide-react';
import { authClient } from '@/lib/auth-client';
import ProfileOverviewPublic from '@/components/profile/ProfileOverviewPublic';
import ProjectsTabPublic from '@/components/profile/ProjectsTabPublic';
import PublicEarningsTab from '@/components/profile/PublicEarningsTab';

interface PublicProfileDataProps {
username: string;
Expand All @@ -32,20 +33,26 @@ const FILTER_OPTIONS = [
'All Time',
];

export function ProfileData({ username }: PublicProfileDataProps) {
const TAB_CLASS =
'data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white';

export function ProfileData({
username,
}: PublicProfileDataProps): React.ReactElement {
const [userData, setUserData] = useState<PublicUserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedFilter, setSelectedFilter] = useState('All');
const [authUser, setAuthUser] = useState<string | null>(null);
const [authUsername, setAuthUsername] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);

useEffect(() => {
async function loadProfile() {
async function loadProfile(): Promise<void> {
try {
setLoading(true);
const { data: session } = await authClient.getSession();
setAuthUser(session?.user?.profile?.username || null);
setAuthUsername(session?.user?.profile?.username || null);
setIsAuthenticated(!!session?.user);
setLoading(true);
const data = await getUserProfileByUsername(username);
setUserData(data);
} catch (err) {
Expand Down Expand Up @@ -87,8 +94,7 @@ export function ProfileData({ username }: PublicProfileDataProps) {
name: org.name,
avatarUrl: org.logo || '/blog1.jpg',
})) || [];
// Determine if it's the user's own profile
const isOwnProfile = isAuthenticated && authUser === userData.username;
const isOwnProfile = isAuthenticated && authUsername === userData.username;

return (
<section className='mt-14 flex flex-col gap-8 lg:flex-row lg:gap-16'>
Expand All @@ -103,21 +109,18 @@ export function ProfileData({ username }: PublicProfileDataProps) {
<Tabs defaultValue='activity' className='w-full'>
<div className='border-b border-zinc-800'>
<TabsList className='h-auto w-full justify-start gap-6 bg-transparent p-0'>
<TabsTrigger
value='activity'
className='data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white'
>
<TabsTrigger value='activity' className={TAB_CLASS}>
Activity
</TabsTrigger>
<TabsTrigger
value='projects'
className='data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white'
>
<TabsTrigger value='projects' className={TAB_CLASS}>
Projects
</TabsTrigger>
<TabsTrigger value='earnings' className={TAB_CLASS}>
Earnings
</TabsTrigger>
<TabsTrigger
value='organizations'
className='data-[state=active]:border-primary rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white md:hidden'
className={`${TAB_CLASS} md:hidden`}
>
Organizations
</TabsTrigger>
Comment thread
Josue19-08 marked this conversation as resolved.
Expand Down Expand Up @@ -163,6 +166,10 @@ export function ProfileData({ username }: PublicProfileDataProps) {
<ProjectsTabPublic user={userData} />
</TabsContent>

<TabsContent value='earnings' className='mt-0'>
<PublicEarningsTab username={username} />
</TabsContent>

{isAuthenticated && isOwnProfile && (
<TabsContent value='organizations' className='mt-0'>
<OrganizationsTab organizations={organizationsData} />
Expand Down
220 changes: 220 additions & 0 deletions components/profile/PublicEarningsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
'use client';

import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import {
Trophy,
Award,
Users,
Target,
TrendingUp,
Loader2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import {
PublicEarningsResponse,
EarningActivity,
EarningSource,
} from '@/types/earnings';
import { getPublicEarnings } from '@/lib/api/earnings';
import EmptyState from '@/components/EmptyState';

interface PublicEarningsTabProps {
username: string;
}

const SOURCE_CONFIG: Record<
EarningSource,
{ label: string; icon: typeof Trophy; color: string }
> = {
hackathons: { label: 'Hackathons', icon: Trophy, color: 'text-amber-400' },
grants: { label: 'Grants', icon: Award, color: 'text-green-400' },
crowdfunding: { label: 'Crowdfunding', icon: Users, color: 'text-blue-400' },
bounties: { label: 'Bounties', icon: Target, color: 'text-purple-400' },
};

const formatCurrency = (amount: number, currency = 'USD'): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};

interface EarningActivityItemProps {
activity: EarningActivity;
}

const EarningActivityItem = ({
activity,
}: EarningActivityItemProps): React.ReactElement => {
const config = SOURCE_CONFIG[activity.source];
const Icon = config.icon;

return (
<div className='flex items-start gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4'>
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-800 ${config.color}`}
>
<Icon className='h-5 w-5' />
</div>
<div className='min-w-0 flex-1'>
<p className='truncate text-sm text-white'>{activity.title}</p>
<p className='mt-1 text-xs text-zinc-500'>
{formatDistanceToNow(new Date(activity.occurredAt), {
addSuffix: true,
})}
</p>
</div>
<div className='shrink-0 text-right'>
<p className='text-primary text-sm font-bold'>
{formatCurrency(activity.amount, activity.currency)}
</p>
<p className={`text-xs ${config.color}`}>{config.label}</p>
</div>
</div>
);
};

const PublicEarningsTab = ({
username,
}: PublicEarningsTabProps): React.ReactElement => {
const [earnings, setEarnings] = useState<PublicEarningsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const controller = new AbortController();

const loadEarnings = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const response = await getPublicEarnings({ username });
if (!controller.signal.aborted) {
setEarnings(response.data);
}
} catch (err) {
if (!controller.signal.aborted) {
setError('Unable to load earnings data');
console.error('Failed to load earnings:', err);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};

loadEarnings();

return () => {
controller.abort();
};
}, [username]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (loading) {
return (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin text-zinc-500' />
</div>
);
}

if (error || !earnings) {
return (
<EmptyState
type='compact'
title='No Earnings Data'
description={error || 'No earnings data available for this user yet.'}
action={<></>}
/>
);
}

const breakdown = earnings.breakdown ?? {
hackathons: 0,
grants: 0,
crowdfunding: 0,
bounties: 0,
};

const breakdownItems = Object.entries(breakdown)
.filter(([, amount]) => amount > 0)
.sort(([, a], [, b]) => b - a) as [EarningSource, number][];

return (
<div className='space-y-6 rounded-xl border border-zinc-800 bg-zinc-900/30 p-6'>
<div className='flex items-center gap-3'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-800'>
<TrendingUp className='text-primary h-6 w-6' />
</div>
<div>
<p className='text-xs text-zinc-500'>Total Earned</p>
<p className='text-2xl font-bold text-white'>
{formatCurrency(earnings.summary?.totalEarned ?? 0)}
</p>
</div>
</div>

<div className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
{breakdownItems.map(([source, amount]) => {
const config = SOURCE_CONFIG[source];
const Icon = config.icon;

return (
<div key={source} className='space-y-2'>
<div className='flex items-center gap-2'>
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-800 ${config.color}`}
>
<Icon className='h-4 w-4' />
</div>
<span className='text-sm text-zinc-500'>{config.label}</span>
</div>
<p className='text-2xl font-bold text-white'>
{formatCurrency(amount)}
</p>
</div>
);
})}
</div>

<div className='flex flex-wrap gap-4 text-xs text-zinc-500'>
{breakdownItems.map(([source]) => {
const config = SOURCE_CONFIG[source];
return (
<span key={source} className='flex items-center gap-2'>
<i
className={`inline-block h-2 w-2 rounded-full ${config.color.replace('text-', 'bg-')}`}
/>
{config.label}
</span>
);
})}
</div>

{(earnings.activities?.length ?? 0) > 0 && (
<div className='space-y-4 border-t border-zinc-800 pt-6'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-zinc-500'>
Verified Activity
</h3>
<div className='space-y-3'>
{(earnings.activities ?? []).map((activity, index) => (
<motion.div
key={`${activity.source}-${activity.occurredAt}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
<EarningActivityItem activity={activity} />
</motion.div>
))}
</div>
</div>
)}
</div>
);
};

export default PublicEarningsTab;
31 changes: 31 additions & 0 deletions lib/api/earnings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { api, ApiResponse } from './api';
import { PublicEarningsResponse } from '@/types/earnings';

export interface GetPublicEarningsParams {
username: string;
limit?: number;
offset?: number;
}

export const getPublicEarnings = async ({
username,
limit = 100,
offset = 0,
}: GetPublicEarningsParams): Promise<ApiResponse<PublicEarningsResponse>> => {
if (!username || typeof username !== 'string') {
throw new Error('Username is required and must be a string');
}

const sanitizedLimit = Math.max(1, Math.min(limit, 100));
const sanitizedOffset = Math.max(0, offset);

const params = new URLSearchParams({
username: username.trim(),
limit: sanitizedLimit.toString(),
offset: sanitizedOffset.toString(),
});

return api.get<PublicEarningsResponse>(
`/users/earnings/public?${params.toString()}`
);
};
28 changes: 28 additions & 0 deletions types/earnings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type EarningSource =
| 'hackathons'
| 'grants'
| 'crowdfunding'
| 'bounties';

export interface EarningActivity {
source: EarningSource;
title: string;
amount: number;
currency: string;
occurredAt: string;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface EarningsBreakdown {
hackathons: number;
grants: number;
crowdfunding: number;
bounties: number;
}

export interface PublicEarningsResponse {
summary: {
totalEarned: number;
};
breakdown: EarningsBreakdown;
activities: EarningActivity[];
}