Skip to content

Commit c97b3f5

Browse files
feat: Implement Organization Profile Page ui (#411)
* feat: Implement Organization Profile Page ui * refactor: Update organization profile handling to use new API structure and improve data fetching --------- Co-authored-by: Collins Ikechukwu <collinschristroa@gmail.com>
1 parent c7a83dd commit c97b3f5

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

app/(landing)/org/[id]/loading.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Skeleton } from '@/components/ui/skeleton';
2+
3+
export default function OrgProfileLoading() {
4+
return (
5+
<section className='mx-auto min-h-screen max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
6+
{/* Banner skeleton */}
7+
<div className='mb-8 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-6 sm:p-8 lg:p-10'>
8+
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
9+
<Skeleton className='h-24 w-24 shrink-0 rounded-2xl sm:h-28 sm:w-28' />
10+
<div className='flex flex-1 flex-col gap-3'>
11+
<Skeleton className='h-9 w-72' />
12+
<Skeleton className='h-5 w-96 max-w-full' />
13+
<div className='flex gap-3'>
14+
<Skeleton className='h-4 w-28' />
15+
<Skeleton className='h-4 w-24' />
16+
</div>
17+
<div className='flex gap-2'>
18+
{[1, 2, 3].map(i => (
19+
<Skeleton key={i} className='h-9 w-9 rounded-lg' />
20+
))}
21+
</div>
22+
</div>
23+
</div>
24+
</div>
25+
26+
{/* Stats grid skeleton */}
27+
<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
28+
{[1, 2, 3, 4].map(i => (
29+
<Skeleton key={i} className='h-32 rounded-xl' />
30+
))}
31+
</div>
32+
33+
{/* About skeleton */}
34+
<div className='space-y-4 rounded-xl border border-zinc-800 bg-zinc-900/30 p-6 lg:p-8'>
35+
<Skeleton className='h-6 w-24' />
36+
<Skeleton className='h-4 w-full' />
37+
<Skeleton className='h-4 w-full' />
38+
<Skeleton className='h-4 w-3/4' />
39+
</div>
40+
</section>
41+
);
42+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import Image from 'next/image';
5+
import {
6+
Trophy,
7+
HandCoins,
8+
FolderKanban,
9+
Target,
10+
Building2,
11+
} from 'lucide-react';
12+
import {
13+
getOrganizationProfile,
14+
OrganizationProfile,
15+
} from '@/lib/api/organization';
16+
import { Skeleton } from '@/components/ui/skeleton';
17+
18+
interface OrgProfileClientProps {
19+
slug: string;
20+
}
21+
22+
export default function OrgProfileClient({ slug }: OrgProfileClientProps) {
23+
const [profile, setProfile] = useState<OrganizationProfile | null>(null);
24+
const [loading, setLoading] = useState(true);
25+
26+
useEffect(() => {
27+
async function loadProfile() {
28+
try {
29+
const data = await getOrganizationProfile(slug);
30+
setProfile(data);
31+
} catch {
32+
setProfile(null);
33+
} finally {
34+
setLoading(false);
35+
}
36+
}
37+
38+
loadProfile();
39+
}, [slug]);
40+
41+
if (loading) {
42+
return <OrgProfileSkeleton />;
43+
}
44+
45+
if (!profile) {
46+
return (
47+
<section className='flex min-h-[50vh] items-center justify-center'>
48+
<p className='text-zinc-500'>Organization not found</p>
49+
</section>
50+
);
51+
}
52+
53+
const { stats } = profile;
54+
const statCards = [
55+
{
56+
label: 'Projects',
57+
value: stats.projectsCount,
58+
icon: FolderKanban,
59+
color: 'text-purple-400',
60+
bgColor: 'bg-purple-500/10',
61+
},
62+
{
63+
label: 'Hackathons',
64+
value: stats.totalHackathons,
65+
icon: Trophy,
66+
color: 'text-[#a7f950]',
67+
bgColor: 'bg-[#a7f950]/10',
68+
},
69+
{
70+
label: 'Bounties',
71+
value: stats.totalBounties,
72+
icon: Target,
73+
color: 'text-amber-400',
74+
bgColor: 'bg-amber-500/10',
75+
},
76+
{
77+
label: 'Grants',
78+
value: stats.totalGrants,
79+
icon: HandCoins,
80+
color: 'text-blue-400',
81+
bgColor: 'bg-blue-500/10',
82+
},
83+
];
84+
85+
return (
86+
<section className='mx-auto max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
87+
{/* Banner / Header */}
88+
<div className='relative mb-8 overflow-hidden rounded-2xl border border-[#a7f950]/20 bg-gradient-to-br from-[#a7f950]/10 via-zinc-900/80 to-zinc-900/40'>
89+
<div className='absolute -top-24 -right-24 h-64 w-64 rounded-full bg-[#a7f950]/5 blur-3xl' />
90+
<div className='absolute -bottom-16 -left-16 h-48 w-48 rounded-full bg-[#a7f950]/5 blur-3xl' />
91+
92+
<div className='relative z-10 p-6 sm:p-8 lg:p-10'>
93+
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
94+
{/* Logo */}
95+
<div className='flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-800/80 backdrop-blur-sm sm:h-28 sm:w-28'>
96+
{profile.logoUrl ? (
97+
<Image
98+
src={profile.logoUrl}
99+
alt={profile.name}
100+
width={112}
101+
height={112}
102+
className='h-full w-full object-cover'
103+
/>
104+
) : (
105+
<Building2 className='h-12 w-12 text-zinc-500' />
106+
)}
107+
</div>
108+
109+
{/* Info */}
110+
<div className='flex flex-1 flex-col gap-3'>
111+
<h1 className='text-3xl font-black text-white lg:text-4xl'>
112+
{profile.name}
113+
</h1>
114+
{profile.description && (
115+
<p className='max-w-2xl text-base leading-relaxed text-gray-300'>
116+
{profile.description}
117+
</p>
118+
)}
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
124+
{/* Stats Grid */}
125+
<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
126+
{statCards.map((stat, index) => {
127+
const Icon = stat.icon;
128+
return (
129+
<div
130+
key={index}
131+
className='group rounded-xl border border-zinc-800 bg-zinc-900/30 p-5 transition-all hover:border-zinc-700 hover:bg-zinc-900/50'
132+
>
133+
<div
134+
className={`mb-4 flex h-10 w-10 items-center justify-center rounded-lg ${stat.bgColor}`}
135+
>
136+
<Icon className={`h-5 w-5 ${stat.color}`} />
137+
</div>
138+
<div className='mb-1 text-3xl font-bold text-white'>
139+
{stat.value}
140+
</div>
141+
<span className='text-sm text-zinc-500'>{stat.label}</span>
142+
</div>
143+
);
144+
})}
145+
</div>
146+
</section>
147+
);
148+
}
149+
150+
function OrgProfileSkeleton() {
151+
return (
152+
<section className='mx-auto max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
153+
<div className='mb-8 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-6 sm:p-8 lg:p-10'>
154+
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
155+
<Skeleton className='h-24 w-24 shrink-0 rounded-2xl sm:h-28 sm:w-28' />
156+
<div className='flex flex-1 flex-col gap-3'>
157+
<Skeleton className='h-9 w-72' />
158+
<Skeleton className='h-5 w-96 max-w-full' />
159+
</div>
160+
</div>
161+
</div>
162+
163+
<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
164+
{[1, 2, 3, 4].map(i => (
165+
<Skeleton key={i} className='h-32 rounded-xl' />
166+
))}
167+
</div>
168+
</section>
169+
);
170+
}

app/(landing)/org/[id]/page.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Metadata } from 'next';
2+
import { getOrganizationProfile } from '@/lib/api/organization';
3+
import OrgProfileClient from './org-profile-client';
4+
5+
interface OrgProfilePageProps {
6+
params: Promise<{ id: string }>;
7+
}
8+
9+
export async function generateMetadata({
10+
params,
11+
}: OrgProfilePageProps): Promise<Metadata> {
12+
try {
13+
const { id: slug } = await params;
14+
const org = await getOrganizationProfile(slug);
15+
16+
return {
17+
title: `${org.name} | Boundless`,
18+
description: org.description || `View ${org.name} on Boundless`,
19+
openGraph: {
20+
title: `${org.name} | Boundless`,
21+
description: org.description || `View ${org.name} on Boundless`,
22+
images: org.logoUrl ? [{ url: org.logoUrl }] : [],
23+
},
24+
twitter: {
25+
card: 'summary',
26+
title: `${org.name} | Boundless`,
27+
description: org.description || `View ${org.name} on Boundless`,
28+
},
29+
};
30+
} catch {
31+
return {
32+
title: 'Organization | Boundless',
33+
description: 'View organization profile on Boundless.',
34+
};
35+
}
36+
}
37+
38+
export default async function OrgProfilePage({ params }: OrgProfilePageProps) {
39+
const { id: slug } = await params;
40+
41+
return <OrgProfileClient slug={slug} />;
42+
}

lib/api/organization.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ export interface Organization {
3131
updatedAt: string;
3232
}
3333

34+
/**
35+
* Public organization profile (by slug) - GET /organizations/profile/:slug
36+
*/
37+
export interface OrganizationProfile {
38+
id: string;
39+
name: string;
40+
logoUrl: string;
41+
description: string;
42+
stats: {
43+
projectsCount: number;
44+
totalHackathons: number;
45+
totalBounties: number;
46+
totalGrants: number;
47+
};
48+
}
49+
3450
export type Role = 'member' | 'admin' | 'owner';
3551

3652
/**
@@ -267,6 +283,18 @@ export const getOrganization = async (
267283
return res.data.data as GetOrganizationResponse;
268284
};
269285

286+
/**
287+
* Get public organization profile by slug (e.g. "boundless-dao")
288+
*/
289+
export const getOrganizationProfile = async (
290+
slug: string
291+
): Promise<OrganizationProfile> => {
292+
const res = await api.get<{ data: OrganizationProfile }>(
293+
`/organizations/profile/${slug}`
294+
);
295+
return res.data.data;
296+
};
297+
270298
/**
271299
* Update organization profile (name, logo, tagline, about)
272300
*/

0 commit comments

Comments
 (0)