Skip to content
Merged
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
42 changes: 42 additions & 0 deletions app/(landing)/org/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Skeleton } from '@/components/ui/skeleton';

export default function OrgProfileLoading() {
return (
<section className='mx-auto min-h-screen max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
{/* Banner skeleton */}
<div className='mb-8 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-6 sm:p-8 lg:p-10'>
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
<Skeleton className='h-24 w-24 shrink-0 rounded-2xl sm:h-28 sm:w-28' />
<div className='flex flex-1 flex-col gap-3'>
<Skeleton className='h-9 w-72' />
<Skeleton className='h-5 w-96 max-w-full' />
<div className='flex gap-3'>
<Skeleton className='h-4 w-28' />
<Skeleton className='h-4 w-24' />
</div>
<div className='flex gap-2'>
{[1, 2, 3].map(i => (
<Skeleton key={i} className='h-9 w-9 rounded-lg' />
))}
</div>
</div>
</div>
</div>

{/* Stats grid skeleton */}
<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stats skeleton layout misses the mobile 1-column requirement.

Line 27 starts at grid-cols-2, so mobile renders 2 columns instead of the required 1. Use grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 to match the test plan.

Proposed fix
-      <div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
+      <div className='mb-8 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-4'>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(landing)/org/[id]/loading.tsx at line 27, The stats skeleton grid
currently uses "grid-cols-2" and renders two columns on mobile; update the class
on the container div in app/(landing)/org/[id]/loading.tsx (the div with the
existing 'mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4' classes) to use
mobile 1-column and responsive breakpoints by replacing the column classes with
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4' so it renders 1 column on mobile, 2
on small screens, and 4 on large.

{[1, 2, 3, 4].map(i => (
<Skeleton key={i} className='h-32 rounded-xl' />
))}
</div>

{/* About skeleton */}
<div className='space-y-4 rounded-xl border border-zinc-800 bg-zinc-900/30 p-6 lg:p-8'>
<Skeleton className='h-6 w-24' />
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-3/4' />
</div>
</section>
);
}
170 changes: 170 additions & 0 deletions app/(landing)/org/[id]/org-profile-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'use client';

import { useEffect, useState } from 'react';
import Image from 'next/image';
import {
Trophy,
HandCoins,
FolderKanban,
Target,
Building2,
} from 'lucide-react';
import {
getOrganizationProfile,
OrganizationProfile,
} from '@/lib/api/organization';
import { Skeleton } from '@/components/ui/skeleton';

interface OrgProfileClientProps {
slug: string;
}

export default function OrgProfileClient({ slug }: OrgProfileClientProps) {
const [profile, setProfile] = useState<OrganizationProfile | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
async function loadProfile() {
try {
const data = await getOrganizationProfile(slug);
setProfile(data);
} catch {
setProfile(null);
} finally {
setLoading(false);
}
}

loadProfile();
}, [slug]);

if (loading) {
return <OrgProfileSkeleton />;
}

if (!profile) {
return (
<section className='flex min-h-[50vh] items-center justify-center'>
<p className='text-zinc-500'>Organization not found</p>
</section>
);
}

const { stats } = profile;
const statCards = [
{
label: 'Projects',
value: stats.projectsCount,
icon: FolderKanban,
color: 'text-purple-400',
bgColor: 'bg-purple-500/10',
},
{
label: 'Hackathons',
value: stats.totalHackathons,
icon: Trophy,
color: 'text-[#a7f950]',
bgColor: 'bg-[#a7f950]/10',
},
{
label: 'Bounties',
value: stats.totalBounties,
icon: Target,
color: 'text-amber-400',
bgColor: 'bg-amber-500/10',
},
{
label: 'Grants',
value: stats.totalGrants,
icon: HandCoins,
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
},
];

return (
<section className='mx-auto max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
{/* Banner / Header */}
<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'>
<div className='absolute -top-24 -right-24 h-64 w-64 rounded-full bg-[#a7f950]/5 blur-3xl' />
<div className='absolute -bottom-16 -left-16 h-48 w-48 rounded-full bg-[#a7f950]/5 blur-3xl' />

<div className='relative z-10 p-6 sm:p-8 lg:p-10'>
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
{/* Logo */}
<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'>
{profile.logoUrl ? (
<Image
src={profile.logoUrl}
alt={profile.name}
width={112}
height={112}
className='h-full w-full object-cover'
/>
) : (
<Building2 className='h-12 w-12 text-zinc-500' />
)}
</div>

{/* Info */}
<div className='flex flex-1 flex-col gap-3'>
<h1 className='text-3xl font-black text-white lg:text-4xl'>
{profile.name}
</h1>
{profile.description && (
<p className='max-w-2xl text-base leading-relaxed text-gray-300'>
{profile.description}
</p>
)}
</div>
</div>
</div>
</div>

{/* Stats Grid */}
<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
{statCards.map((stat, index) => {
Comment on lines +125 to +126
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stats grid breakpoints are off for mobile.

Line 232 and Line 291 start from grid-cols-2; the requirement is mobile 1-column, tablet 2-column, desktop 4-column.

Proposed fix
-      <div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
+      <div className='mb-8 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-4'>
...
-      <div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
+      <div className='mb-8 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-4'>

Also applies to: 291-292

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(landing)/org/[id]/org-profile-client.tsx around lines 232 - 233, The
stats grid currently uses "grid grid-cols-2 ... lg:grid-cols-4" which makes
mobile show two columns; update both occurrences that render the stats grid (the
JSX mapping over statCards) to use responsive classes for 1 column on mobile, 2
on tablet, and 4 on desktop by replacing the class sequence to include
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" (keep existing gap and other classes
intact) so the component rendering statCards and its duplicate instance follow
the required breakpoints.

const Icon = stat.icon;
return (
<div
key={index}
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'
>
<div
className={`mb-4 flex h-10 w-10 items-center justify-center rounded-lg ${stat.bgColor}`}
>
<Icon className={`h-5 w-5 ${stat.color}`} />
</div>
<div className='mb-1 text-3xl font-bold text-white'>
{stat.value}
</div>
<span className='text-sm text-zinc-500'>{stat.label}</span>
</div>
);
})}
</div>
</section>
);
}

function OrgProfileSkeleton() {
return (
<section className='mx-auto max-w-[1440px] px-5 py-10 md:px-[50px] lg:px-[100px]'>
<div className='mb-8 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-6 sm:p-8 lg:p-10'>
<div className='flex flex-col gap-6 sm:flex-row sm:items-start'>
<Skeleton className='h-24 w-24 shrink-0 rounded-2xl sm:h-28 sm:w-28' />
<div className='flex flex-1 flex-col gap-3'>
<Skeleton className='h-9 w-72' />
<Skeleton className='h-5 w-96 max-w-full' />
</div>
</div>
</div>

<div className='mb-8 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4'>
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} className='h-32 rounded-xl' />
))}
</div>
</section>
);
}
42 changes: 42 additions & 0 deletions app/(landing)/org/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Metadata } from 'next';
import { getOrganizationProfile } from '@/lib/api/organization';
import OrgProfileClient from './org-profile-client';

interface OrgProfilePageProps {
params: Promise<{ id: string }>;
}

export async function generateMetadata({
params,
}: OrgProfilePageProps): Promise<Metadata> {
try {
const { id: slug } = await params;
const org = await getOrganizationProfile(slug);

return {
title: `${org.name} | Boundless`,
description: org.description || `View ${org.name} on Boundless`,
openGraph: {
title: `${org.name} | Boundless`,
description: org.description || `View ${org.name} on Boundless`,
images: org.logoUrl ? [{ url: org.logoUrl }] : [],
},
twitter: {
card: 'summary',
title: `${org.name} | Boundless`,
description: org.description || `View ${org.name} on Boundless`,
},
};
} catch {
return {
title: 'Organization | Boundless',
description: 'View organization profile on Boundless.',
};
}
}

export default async function OrgProfilePage({ params }: OrgProfilePageProps) {
const { id: slug } = await params;

return <OrgProfileClient slug={slug} />;
}
28 changes: 28 additions & 0 deletions lib/api/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ export interface Organization {
updatedAt: string;
}

/**
* Public organization profile (by slug) - GET /organizations/profile/:slug
*/
export interface OrganizationProfile {
id: string;
name: string;
logoUrl: string;
description: string;
stats: {
projectsCount: number;
totalHackathons: number;
totalBounties: number;
totalGrants: number;
};
}

export type Role = 'member' | 'admin' | 'owner';

/**
Expand Down Expand Up @@ -267,6 +283,18 @@ export const getOrganization = async (
return res.data.data as GetOrganizationResponse;
};

/**
* Get public organization profile by slug (e.g. "boundless-dao")
*/
export const getOrganizationProfile = async (
slug: string
): Promise<OrganizationProfile> => {
const res = await api.get<{ data: OrganizationProfile }>(
`/organizations/profile/${slug}`
);
return res.data.data;
};

/**
* Update organization profile (name, logo, tagline, about)
*/
Expand Down