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
163 changes: 92 additions & 71 deletions components/cards/opportunity-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Image from 'next/image';
import Link from 'next/link';
import type { ReactNode } from 'react';

import { deriveInitials } from '@/lib/initials';
import { cn } from '@/lib/utils';

import type { OpportunityCardStatus, OpportunityCardView } from './types';
Expand Down Expand Up @@ -81,96 +82,116 @@ export function OpportunityCard({
participants,
mode,
comments,
logo,
endsIn,
reward,
detailUrl,
} = opportunity;

return (
<Link
href={detailUrl}
aria-label={title}
className='group block min-w-0 rounded-2xl focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:outline-none'
const card = (
<article
className={cn(
'flex min-w-0 flex-col gap-5 rounded-2xl border border-[#1f2a28] bg-ink p-4',
detailUrl &&
'transition-[transform,border-color] duration-200 group-hover:border-[#2a3a37] motion-reduce:transition-none'
)}
>
<article className='flex min-w-0 flex-col gap-5 rounded-2xl border border-[#1f2a28] bg-ink p-4 transition-[transform,border-color] duration-200 group-hover:border-[#2a3a37] motion-reduce:transition-none'>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{logo ? (
<Image
src='/brand/test.png'
src={logo}
width={24}
height={24}
alt={org}
className='size-6 shrink-0 rounded-full bg-[#fefefe] text-[10px] font-bold text-white'
unoptimized
className='size-6 shrink-0 rounded-full bg-[#fefefe] object-cover'
/>
<span className='truncate text-sm font-medium text-muted-foreground'>
{org}
) : (
<span className='flex size-6 shrink-0 items-center justify-center rounded-full bg-[#fefefe] text-[10px] font-bold text-neutral-900'>
{deriveInitials(title)}
</span>
<span className='shrink-0 text-sm font-medium text-muted-foreground'>
#{index}
</span>
</div>
<StatusBadge status={status} />
)}
<span className='truncate text-sm font-medium text-muted-foreground'>
{org}
</span>
<span className='shrink-0 text-sm font-medium text-muted-foreground'>
#{index}
</span>
</div>
<StatusBadge status={status} />
</div>

<h3 className='line-clamp-2 min-h-[52px] text-base font-semibold text-foreground'>
{title}
</h3>
<h3 className='line-clamp-2 min-h-[52px] text-base font-semibold text-foreground'>
{title}
</h3>

{/* Single row: short items hold their size, the mode label truncates. */}
<div className='flex min-w-0 items-center gap-3'>
<span className='flex shrink-0 items-center gap-1'>
<Tag
className='size-4 shrink-0 text-muted-foreground'
strokeWidth={1.75}
aria-hidden
/>
<span className='rounded-[12px] bg-[rgba(234,253,247,0.08)] px-2 py-0.5 text-xs font-medium text-primary-700'>
{category}
</span>
{/* Single row: short items hold their size, the mode label truncates. */}
<div className='flex min-w-0 items-center gap-3'>
<span className='flex shrink-0 items-center gap-1'>
<Tag
className='size-4 shrink-0 text-muted-foreground'
strokeWidth={1.75}
aria-hidden
/>
<span className='rounded-[12px] bg-[rgba(234,253,247,0.08)] px-2 py-0.5 text-xs font-medium text-primary-700'>
{category}
</span>
<Meta icon={Users} className='shrink-0'>
{participants}
</Meta>
<Meta icon={Layers} className='min-w-0'>
{mode}
</Meta>
<Meta icon={MessageCircleMore} className='shrink-0'>
{comments}
</Meta>
</div>
</span>
<Meta icon={Users} className='shrink-0'>
{participants}
</Meta>
<Meta icon={Layers} className='min-w-0'>
{mode}
</Meta>
<Meta icon={MessageCircleMore} className='shrink-0'>
{comments}
</Meta>
</div>

{endsIn || reward ? (
<>
<span aria-hidden className='h-px w-full bg-[#1f2a28]' />
{endsIn || reward ? (
<>
<span aria-hidden className='h-px w-full bg-[#1f2a28]' />

<div className='flex items-center gap-3'>
{endsIn ? (
<span className='flex min-w-0 flex-1 items-center gap-1 text-xs font-medium text-info-500'>
<Calendar
className='size-4 shrink-0'
strokeWidth={1.75}
aria-hidden
/>
<span className='truncate'>Ends in {endsIn}</span>
</span>
) : null}
{reward ? (
<span className='ml-auto flex shrink-0 items-center gap-2'>
<Image
src='/currency/usdc.png'
alt='Token'
width={20}
height={20}
/>
<span className='text-xl font-bold tracking-tight whitespace-nowrap text-primary-600'>
{reward.amount.toLocaleString()} {reward.currency}
</span>
<div className='flex items-center gap-3'>
{endsIn ? (
<span className='flex min-w-0 flex-1 items-center gap-1 text-xs font-medium text-info-500'>
<Calendar
className='size-4 shrink-0'
strokeWidth={1.75}
aria-hidden
/>
<span className='truncate'>Ends in {endsIn}</span>
</span>
) : null}
{reward ? (
<span className='ml-auto flex shrink-0 items-center gap-2'>
<Image
src='/currency/usdc.png'
alt='Token'
width={20}
height={20}
/>
<span className='text-xl font-bold tracking-tight whitespace-nowrap text-primary-600'>
{reward.amount.toLocaleString()} {reward.currency}
</span>
) : null}
</div>
</>
) : null}
</article>
</span>
) : null}
</div>
</>
) : null}
</article>
);

if (!detailUrl) return card;

return (
<Link
href={detailUrl}
aria-label={title}
className='group block min-w-0 rounded-2xl focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:outline-none'
>
{card}
</Link>
);
}
6 changes: 4 additions & 2 deletions components/cards/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ export interface OpportunityCardView {
/** Sub-mode label or pillar name. Empty string when nothing better is available. */
mode: string;
comments: number;
/** Optional project logo image URL. */
logo?: string;
/** Pre-formatted countdown like `4D:22H:49M` when a deadline is available. */
endsIn?: string;
reward?: { amount: number; currency: string };
/** Detail page path so cards can link out. */
detailUrl: string;
/** Detail page URL so cards can link out. Absent when there is no target. */
detailUrl?: string;
isFeatured: boolean;
}
43 changes: 28 additions & 15 deletions components/discover/builders-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';
"use client";

import { BuilderCard } from '@/components/cards/builder-card';
import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton';
import { Pagination } from '@/components/ui/pagination';
import { BuilderCard } from "@/components/cards/builder-card";
import { BuilderCardSkeleton } from "@/components/cards/builder-card-skeleton";
import { Pagination } from "@/components/ui/pagination";

import { toDirectoryBuilderCard } from './to-builder-directory-card';
import type { BuildersDirectoryResponse } from './use-builders';
import { toDirectoryBuilderCard } from "./to-builder-directory-card";
import type { BuildersDirectoryResponse } from "./use-builders";

const GRID_CLASS = 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4';
const GRID_CLASS = "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4";

export function BuildersGrid({
data,
Expand All @@ -17,6 +17,7 @@ export function BuildersGrid({
page,
pageSize,
onPageChange,
onHelpClick,
}: {
data?: BuildersDirectoryResponse;
isPending: boolean;
Expand All @@ -26,6 +27,8 @@ export function BuildersGrid({
page: number;
pageSize: number;
onPageChange: (page: number) => void;
/** Called when the user clicks "How to get listed" in the empty state. */
onHelpClick?: () => void;
}) {
if (isPending) {
return (
Expand All @@ -39,24 +42,34 @@ export function BuildersGrid({

if (isError || !data) {
return (
<p className='py-12 text-center text-body-sm text-muted-foreground'>
<p className="py-12 text-center text-body-sm text-muted-foreground">
Builders could not be loaded right now.
</p>
);
}

if (data.data.length === 0) {
return (
<p className='py-12 text-center text-body-sm text-muted-foreground'>
{isNarrowed
? 'No builders match these filters.'
: 'No builders to show yet.'}
</p>
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="text-body-sm text-muted-foreground">
{isNarrowed
? "No builders match these filters."
: "Builders appear here once their profile is complete and they have shipped work on Boundless."}
</p>
{!isNarrowed && onHelpClick && (
<button
onClick={onHelpClick}
className="text-primary-500 hover:text-primary-400 text-sm underline transition-colors"
>
How to get listed
</button>
)}
</div>
);
}

return (
<div className='flex flex-col gap-8'>
<div className="flex flex-col gap-8">
<div className={GRID_CLASS}>
{data.data.map((builder) => (
<BuilderCard
Expand All @@ -72,7 +85,7 @@ export function BuildersGrid({
pageSize={pageSize}
totalItems={data.pagination.total}
onPageChange={onPageChange}
className='justify-between'
className="justify-between"
/>
)}
</div>
Expand Down
Loading