Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
77b2fd1
feat(bounty): features/bounties data layer (types, keys, draft + escr…
Benjtalkshow Jun 16, 2026
5c0d24e
feat(bounty): wizard shell + step/draft machinery (#598)
Benjtalkshow Jun 16, 2026
df74eb0
feat: implement multi-step crowdfunding campaign wizard with mileston…
0xdevcollins Jun 18, 2026
9865cdf
feat(crowdfunding): v2 public pages, builder dashboard, milestone tra…
0xdevcollins Jun 24, 2026
17402a7
chore: update lockfile dependencies for package-lock.json
0xdevcollins Jun 24, 2026
6241d60
merge: feat/t-replace — bounty wizard completion (scope/reward/resour…
0xdevcollins Jun 24, 2026
b4e5cc5
refactor: standardize API error extraction to support array-based mes…
0xdevcollins Jun 24, 2026
0718279
refactor: restructure StoryStep inputs, enable sidebar navigation, an…
0xdevcollins Jun 24, 2026
7d9c557
merge: resolve conflicts with origin/feat/t-replace
0xdevcollins Jun 24, 2026
947b154
feat: replace textarea with AnnouncementEditor and add markdown rende…
0xdevcollins Jun 24, 2026
30b60a9
merge: resolve conflict with origin/feat/t-replace in StoryStep
0xdevcollins Jun 24, 2026
8c73c9f
fix: useCampaign routes UUID params to fetchCampaignById
0xdevcollins Jun 24, 2026
286f0db
feat: auth-gate voting/contribute + branded fallback banner
0xdevcollins Jun 24, 2026
e3a54a5
merge: resolve stash-pop conflict in public campaign page
0xdevcollins Jun 24, 2026
275579c
refactor: unify campaign description rendering logic to prioritize de…
0xdevcollins Jun 24, 2026
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
120 changes: 92 additions & 28 deletions app/(landing)/crowdfunding/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ExternalLink,
ShieldCheck,
CalendarDays,
LogIn,
} from 'lucide-react';

import { useCampaign } from '@/features/crowdfunding';
Expand Down Expand Up @@ -133,9 +134,9 @@ export default function PublicCampaignPage({ params }: PageProps) {
const { slug } = use(params);
const { data: campaign, isLoading } = useCampaign(slug);
const [sheetOpen, setSheetOpen] = useState(false);
const { user } = useAuthStatus();
const { user, isAuthenticated } = useAuthStatus();
const { styledContent: descriptionContent } = useMarkdown(
campaign?.project?.details ?? ''
campaign?.project?.details ?? campaign?.project?.description ?? ''
);

if (isLoading) {
Expand Down Expand Up @@ -212,20 +213,74 @@ export default function PublicCampaignPage({ params }: PageProps) {
</div>

{/* Hero */}
{project.banner ? (
<div className='relative h-56 w-full overflow-hidden sm:h-72'>
<div className='relative h-56 w-full overflow-hidden sm:h-72'>
{project.banner ? (
<Image
src={project.banner}
alt={project.title}
fill
className='object-cover'
priority
/>
<div className='absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/40 to-transparent' />
</div>
) : (
<div className='h-24 w-full bg-gradient-to-br from-zinc-900 to-zinc-800' />
)}
) : (
<svg
viewBox='0 0 1200 400'
xmlns='http://www.w3.org/2000/svg'
className='absolute inset-0 h-full w-full'
preserveAspectRatio='xMidYMid slice'
>
<defs>
<radialGradient id='bg-glow' cx='75%' cy='35%' r='55%'>
<stop offset='0%' stopColor='#2EEDAA' stopOpacity='0.07' />
<stop offset='100%' stopColor='#09090b' stopOpacity='0' />
</radialGradient>
<pattern
id='dot-grid'
x='0'
y='0'
width='32'
height='32'
patternUnits='userSpaceOnUse'
>
<circle cx='16' cy='16' r='1' fill='#3f3f46' opacity='0.6' />
</pattern>
</defs>
{/* Base background */}
<rect width='1200' height='400' fill='#09090b' />
{/* Dot grid */}
<rect width='1200' height='400' fill='url(#dot-grid)' />
{/* Glow */}
<rect width='1200' height='400' fill='url(#bg-glow)' />
{/* Concentric arcs from bottom-left — Boundless brand identity */}
{[180, 320, 460, 600, 740, 880, 1020, 1180].map((r, i) => (
<path
key={r}
d={`M ${r},400 A ${r},${r} 0 0,0 0,${400 - r}`}
stroke='#2EEDAA'
strokeWidth='1'
fill='none'
opacity={Math.max(0.04, 0.22 - i * 0.025)}
/>
))}
{/* Boundless wordmark — very faint watermark */}
<text
x='600'
y='230'
textAnchor='middle'
dominantBaseline='middle'
fontFamily='system-ui, -apple-system, sans-serif'
fontWeight='700'
fontSize='140'
letterSpacing='-4'
fill='#2EEDAA'
opacity='0.03'
>
Boundless
</text>
</svg>
)}
<div className='absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/40 to-transparent' />
</div>

<div className='container mx-auto max-w-5xl px-4 pb-20'>
{/* Title row */}
Expand Down Expand Up @@ -264,12 +319,8 @@ export default function PublicCampaignPage({ params }: PageProps) {
{/* Story */}
<Section title='About this campaign'>
<div className='text-zinc-300'>
{project.details ? (
{(project.details ?? project.description) ? (
descriptionContent
) : project.vision || project.description ? (
<p className='leading-relaxed whitespace-pre-line'>
{project.vision || project.description}
</p>
) : (
<span className='text-zinc-600 italic'>
No description provided.
Expand Down Expand Up @@ -527,20 +578,33 @@ export default function PublicCampaignPage({ params }: PageProps) {
</p>
</div>
) : isFunding && !isFullyFunded ? (
<>
<Button
onClick={() => setSheetOpen(true)}
className='bg-primary hover:bg-primary/90 w-full'
size='lg'
>
Back this project
</Button>
<p className='mt-3 flex items-start gap-2 text-xs text-zinc-500'>
<ShieldCheck className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-zinc-600' />
Your support is held safely and released to the team as
each milestone is delivered and approved.
</p>
</>
isAuthenticated ? (
<>
<Button
onClick={() => setSheetOpen(true)}
className='bg-primary hover:bg-primary/90 w-full'
size='lg'
>
Back this project
</Button>
<p className='mt-3 flex items-start gap-2 text-xs text-zinc-500'>
<ShieldCheck className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-zinc-600' />
Your support is held safely and released to the team as
each milestone is delivered and approved.
</p>
</>
) : (
<Link href='/auth' className='block'>
<Button
variant='outline'
className='w-full gap-2 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-white'
size='lg'
>
<LogIn className='h-4 w-4' />
Sign in to back this project
</Button>
</Link>
)
) : isFullyFunded ? (
<div className='border-primary/30 bg-primary/10 rounded-lg border px-4 py-3 text-center'>
<p className='text-primary text-sm font-semibold'>
Expand Down
5 changes: 1 addition & 4 deletions app/me/crowdfunding/[slug]/components/ProjectDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ interface ProjectDetailsProps {
// Identity (logo, title, status) lives in the shared campaign layout header,
// so this is just the campaign story — no duplicated title/status/date.
export function ProjectDetails({ project }: ProjectDetailsProps) {
// `vision` is the canonical "Project story" the wizard writes; description /
// details are legacy fields. Match the public page's precedence so the same
// campaign shows the same story on both surfaces.
const body = project.vision || project.description || project.details || '';
const body = project.details ?? project.description ?? '';
const { styledContent } = useMarkdown(body, {
breaks: true,
gfm: true,
Expand Down
77 changes: 44 additions & 33 deletions components/crowdfunding/VotePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
'use client';

import { ThumbsUp, ThumbsDown } from 'lucide-react';
import { ThumbsUp, ThumbsDown, LogIn } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'sonner';
import { extractApiErrorMessage } from '@/lib/api/api';

import { useCastVote, useMyVote } from '@/features/crowdfunding';
import type { Crowdfunding } from '@/features/projects/types';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAuthStatus } from '@/hooks/use-auth';

/**
* Public voting panel shown while a campaign is in community review. Anyone
* signed in can vote yes/no; the tally fills toward the campaign's quorum.
*/
export function VotePanel({ campaign }: { campaign: Crowdfunding }) {
const { isAuthenticated } = useAuthStatus();
const vote = useCastVote();
const { data: myVoteRaw } = useMyVote(campaign.id);
const current = (myVoteRaw as { choice?: 'UP' | 'DOWN' } | null)?.choice;
Expand Down Expand Up @@ -55,34 +54,46 @@ export function VotePanel({ campaign }: { campaign: Crowdfunding }) {
</p>
</div>

<div className='grid grid-cols-2 gap-2'>
<Button
onClick={() => cast('UP')}
disabled={vote.isPending}
className={cn(
'gap-2',
current === 'UP'
? 'bg-primary hover:bg-primary/90'
: 'border border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800'
)}
>
<ThumbsUp className='h-4 w-4' />
Yes
</Button>
<Button
onClick={() => cast('DOWN')}
disabled={vote.isPending}
className={cn(
'gap-2',
current === 'DOWN'
? 'bg-red-600 text-white hover:bg-red-500'
: 'border border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800'
)}
>
<ThumbsDown className='h-4 w-4' />
No
</Button>
</div>
{isAuthenticated ? (
<div className='grid grid-cols-2 gap-2'>
<Button
onClick={() => cast('UP')}
disabled={vote.isPending}
className={cn(
'gap-2',
current === 'UP'
? 'bg-primary hover:bg-primary/90'
: 'border border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800'
)}
>
<ThumbsUp className='h-4 w-4' />
Yes
</Button>
<Button
onClick={() => cast('DOWN')}
disabled={vote.isPending}
className={cn(
'gap-2',
current === 'DOWN'
? 'bg-red-600 text-white hover:bg-red-500'
: 'border border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800'
)}
>
<ThumbsDown className='h-4 w-4' />
No
</Button>
</div>
) : (
<Link href='/auth' className='block'>
<Button
variant='outline'
className='w-full gap-2 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-white'
>
<LogIn className='h-4 w-4' />
Sign in to vote
</Button>
</Link>
)}

<div>
<div className='flex items-center justify-between text-xs text-zinc-500'>
Expand Down
2 changes: 1 addition & 1 deletion components/crowdfunding/new/NewCampaignWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function mapCampaignToWizard(c: CrowdfundingCampaign): CampaignWizardData {
category: p?.category ?? '',
logoUrl: p?.logo ?? '',
bannerUrl: p?.banner ?? '',
vision: p?.details ?? '', // story <-> project.details
vision: p?.details ?? p?.description ?? '', // story <-> project.details (fallback: project.description for pre-fix data)
fundingGoal: c.fundingGoal ?? 0,
milestones,
teamMembers,
Expand Down
8 changes: 7 additions & 1 deletion features/crowdfunding/api/use-campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
fetchCampaigns,
fetchMyCampaigns,
fetchCampaignBySlug,
fetchCampaignById,
submitForReview,
withdrawSubmission,
reviseAndResubmit,
Expand All @@ -19,6 +20,9 @@ import {
castVote,
fetchMyVote,
} from './campaign-client';

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
import type { ContributeV2Body } from '../types';

/** Funding window opened at launch (admins can extend it afterward). */
Expand Down Expand Up @@ -58,12 +62,14 @@ export function useMyCampaigns(

/** Single campaign by slug or ID. Disabled until a value is provided. */
export function useCampaign(idOrSlug: string | null | undefined) {
const isId = Boolean(idOrSlug && UUID_RE.test(idOrSlug));
return useQuery({
queryKey: idOrSlug
? crowdfundingKeys.campaign(idOrSlug)
: [...crowdfundingKeys.all, 'campaign', 'idle'],
enabled: Boolean(idOrSlug),
queryFn: () => fetchCampaignBySlug(idOrSlug!),
queryFn: () =>
isId ? fetchCampaignById(idOrSlug!) : fetchCampaignBySlug(idOrSlug!),
staleTime: 30_000,
});
}
Expand Down