Skip to content

Commit 495689f

Browse files
0xdevcollinsBenjtalkshowclaude
authored
Feat/crowdfunding (#642)
* feat(bounty): features/bounties data layer (types, keys, draft + escrow) (#596) Adds the features/bounties/ data layer for the v1 app, mirroring features/hackathons/. REST-only via the typed openapi-fetch client; every server shape is aliased from the backend-generated schema so it cannot drift. - types.ts: aliases the generated draft + escrow DTOs (BountyDraft, UpdateBountyDraftBody, the four section types, BountyEscrowOpResponse, PublishBountyEscrowRequest, ...). Derives the two-axis taxonomy from the generated mode DTO, superseding the local stubs in the ModeTab. - api/keys.ts: bountyKeys factory. - api/draft-client.ts + use-draft.ts: imperative CRUD plus the useDraft / useDraftList / useCreateDraft / useUpdateDraft / useDeleteDraft hooks against /organizations/{organizationId}/bounties/draft[/{id}] + /drafts. - api/escrow-client.ts + use-escrow.ts: organizer escrow calls (publish / cancel / select-winners / submit-signed / poll) plus useEscrowOp + useEscrowOpRunner, mirroring the hackathon machinery (MANAGED polls; EXTERNAL signs -> submit -> poll) bounty-scoped. - index.ts: public surface. Regenerates lib/api/generated/schema.d.ts from the v2 backend so the bounty draft paths/DTOs are present. * feat(bounty): wizard shell + step/draft machinery (#598) Adds the bounty Configure wizard orchestrator and its step + draft state, mirroring the hackathon wizard. No AI assist. - components/organization/bounties/new/constants.ts: StepKey (scope/mode/submission/reward/review), STEP_ORDER, BountyFormData, and isBountyStepDataValid. - hooks/use-bounty-steps.ts: URL ?step= navigation (free-roam) with a presentational step-status map. - hooks/use-bounty-draft.ts: lazy create (ensureDraftId) then per-section PATCH, resume via useDraft, and transformBountyFromApi (sections -> form state; winnerCount derived from prize tiers, ISO dates trimmed for the inputs). - components/organization/bounties/new/NewBountyTab.tsx: orchestrator wiring steps + draft + per-step save, persisting ?draftId= for resume, and threading the chosen mode from ModeTab into SubmissionModelTab. Scope/Reward/Review tabs (#600) and the publish + funding flow (#601) are left as marked placeholders with their save/navigate/draftId seams in place; this satisfies the acceptance criteria (navigate, autosave, resume) without speculative publish UI that depends on the unbuilt publish hook. * feat: implement multi-step crowdfunding campaign wizard with milestone management * feat(crowdfunding): v2 public pages, builder dashboard, milestone tracking - Redesigned public listing (ProjectCard) and detail page with proper lifecycle states, voting panel, contributor list, and fully-funded detection - Added public milestone list + detail pages under /crowdfunding/[slug]/milestones - Builder per-campaign management: tabbed layout (overview / milestones / contributions) with shared header showing milestone X/Y progress and Fully Funded badge once fundingRaised >= fundingGoal - Milestone status sourced from milestoneState() utility: claimedAt-based "paid out" detection replaces stale reviewStatus === 'completed' checks - CampaignStatusBanner: shows milestone delivery bar and Fully Funded label during the FUNDING phase when goal is reached - milestones-metrics: completedAmount correctly sums paid-out milestones; inProgress checks SUBMITTED/UNDER_REVIEW enum values - ProjectCard: switches from funding bar to milestone X/Y bar on fully funded; footer and status badge update to Fully Funded (green) - lib/crowdfunding/status.ts: single source of truth for all campaign and milestone status copy and tone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update lockfile dependencies for package-lock.json * refactor: standardize API error extraction to support array-based messages across crowdfunding components * refactor: restructure StoryStep inputs, enable sidebar navigation, and update campaign wizard validation logic --------- Co-authored-by: Benjtalkshow <chinedubenj@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a0f17e0 commit 495689f

9 files changed

Lines changed: 457 additions & 218 deletions

File tree

components/crowdfunding/CampaignStatusBanner.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from 'lucide-react';
1717
import { cn } from '@/lib/utils';
1818
import { toast } from 'sonner';
19+
import { extractApiErrorMessage } from '@/lib/api/api';
1920
import {
2021
useWithdrawSubmission,
2122
usePublishCampaign,
@@ -193,11 +194,9 @@ export function CampaignStatusBanner({ campaign, onStatusChange }: Props) {
193194
setLaunchOpen(false);
194195
onStatusChange?.();
195196
},
196-
onError: err =>
197+
onError: (err: unknown) =>
197198
toast.error(
198-
err instanceof Error
199-
? err.message
200-
: 'Failed to launch. Please try again.'
199+
extractApiErrorMessage(err, 'Failed to launch. Please try again.')
201200
),
202201
});
203202
};
@@ -286,8 +285,13 @@ export function CampaignStatusBanner({ campaign, onStatusChange }: Props) {
286285
toast.success('Submission withdrawn.');
287286
onStatusChange?.();
288287
},
289-
onError: () =>
290-
toast.error('Something went wrong. Please try again.'),
288+
onError: (err: unknown) =>
289+
toast.error(
290+
extractApiErrorMessage(
291+
err,
292+
'Something went wrong. Please try again.'
293+
)
294+
),
291295
})
292296
}
293297
className='text-zinc-400 hover:text-white'

components/crowdfunding/ContributeSheet.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Switch } from '@/components/ui/switch';
1616
import { Loader2, Wallet, Zap, CheckCircle2 } from 'lucide-react';
1717
import { cn } from '@/lib/utils';
1818
import { toast } from 'sonner';
19+
import { extractApiErrorMessage } from '@/lib/api/api';
1920
import { useQueryClient } from '@tanstack/react-query';
2021
import {
2122
contributeV2,
@@ -118,9 +119,7 @@ export function ContributeSheet({
118119
setDone(true);
119120
} catch (err) {
120121
toast.error(
121-
err instanceof Error
122-
? err.message
123-
: 'Contribution failed. Please try again.'
122+
extractApiErrorMessage(err, 'Contribution failed. Please try again.')
124123
);
125124
} finally {
126125
setSubmitting(false);

components/crowdfunding/VotePanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { ThumbsUp, ThumbsDown } from 'lucide-react';
44
import { toast } from 'sonner';
5+
import { extractApiErrorMessage } from '@/lib/api/api';
56

67
import { useCastVote, useMyVote } from '@/features/crowdfunding';
78
import type { Crowdfunding } from '@/features/projects/types';
@@ -31,11 +32,12 @@ export function VotePanel({ campaign }: { campaign: Crowdfunding }) {
3132
toast.success(
3233
choice === 'UP' ? 'Thanks, your vote is in.' : 'Your vote is in.'
3334
),
34-
onError: err =>
35+
onError: (err: unknown) =>
3536
toast.error(
36-
err instanceof Error
37-
? err.message
38-
: 'Could not record your vote. Make sure you are signed in.'
37+
extractApiErrorMessage(
38+
err,
39+
'Could not record your vote. Make sure you are signed in.'
40+
)
3941
),
4042
}
4143
);

components/crowdfunding/new/NewCampaignSidebar.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ interface NewCampaignSidebarProps {
1212
activeStep: number;
1313
completedSteps: Set<number>;
1414
campaignId?: string;
15+
onStepClick?: (step: number) => void;
1516
}
1617

1718
interface SidebarContentProps {
1819
activeStep: number;
1920
completedSteps: Set<number>;
21+
onStepClick?: (step: number) => void;
2022
}
2123

22-
function SidebarContent({ activeStep, completedSteps }: SidebarContentProps) {
24+
function SidebarContent({
25+
activeStep,
26+
completedSteps,
27+
onStepClick,
28+
}: SidebarContentProps) {
2329
return (
2430
<nav className='flex h-full flex-col overflow-y-auto px-4 py-6'>
2531
<div className='mb-8'>
@@ -45,6 +51,9 @@ function SidebarContent({ activeStep, completedSteps }: SidebarContentProps) {
4551
return (
4652
<div
4753
key={step.key}
54+
onClick={() => {
55+
if (!isLocked && !isActive) onStepClick?.(stepNumber);
56+
}}
4857
className={cn(
4958
'group relative flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-medium transition-all',
5059
isActive
@@ -136,6 +145,7 @@ function SidebarContent({ activeStep, completedSteps }: SidebarContentProps) {
136145
export default function NewCampaignSidebar({
137146
activeStep,
138147
completedSteps,
148+
onStepClick,
139149
}: NewCampaignSidebarProps) {
140150
const { height } = useWindowSize();
141151
const headerHeight = 64;
@@ -157,6 +167,7 @@ export default function NewCampaignSidebar({
157167
<SidebarContent
158168
activeStep={activeStep}
159169
completedSteps={completedSteps}
170+
onStepClick={onStepClick}
160171
/>
161172
</SheetContent>
162173
</Sheet>

components/crowdfunding/new/NewCampaignWizard.tsx

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
updateCrowdfundingProject,
2424
submitCampaignForReview,
2525
} from '@/features/projects/api';
26+
import { extractApiErrorMessage } from '@/lib/api/api';
2627

2728
export interface WizardMilestone {
2829
title: string;
@@ -83,14 +84,14 @@ function validateStep(step: number, d: CampaignWizardData): string | null {
8384
case 1:
8485
if (!d.title.trim() || d.title.length < 3)
8586
return 'Campaign title is required (min 3 characters)';
86-
if (!d.tagline.trim() || d.tagline.length < 10)
87-
return 'Tagline is required (min 10 characters)';
8887
if (!d.category) return 'Please select a category';
8988
if (!d.logoUrl) return 'Please upload a campaign logo before continuing';
9089
return null;
9190
case 2:
91+
if (!d.tagline.trim() || d.tagline.length < 10)
92+
return 'Vision is required (min 10 characters)';
9293
if (!d.vision.trim() || d.vision.length < 50)
93-
return 'Project story must be at least 50 characters';
94+
return 'Project description must be at least 50 characters';
9495
return null;
9596
case 3:
9697
if (!d.fundingGoal || d.fundingGoal < 1)
@@ -144,13 +145,18 @@ function buildDraftPayload(data: CampaignWizardData): Record<string, unknown> {
144145
if (data.fundingGoal >= 1) payload.fundingAmount = data.fundingGoal;
145146
if (data.githubUrl) payload.githubUrl = data.githubUrl;
146147
if (data.gitlabUrl) payload.gitlabUrl = data.gitlabUrl;
147-
if (data.websiteUrl) {
148-
payload.websiteUrl = data.websiteUrl;
149-
payload.projectWebsite = data.websiteUrl;
150-
}
148+
if (data.websiteUrl) payload.projectWebsite = data.websiteUrl;
151149
if (data.demoVideoUrl) payload.demoVideo = data.demoVideoUrl;
152150

153-
const socialLinks = data.socialLinks.filter(s => s.platform && s.url);
151+
// Deduplicate by platform so @ArrayUnique never fires.
152+
const seen = new Set<string>();
153+
const socialLinks = data.socialLinks
154+
.filter(s => s.platform && s.url)
155+
.filter(s => {
156+
if (seen.has(s.platform)) return false;
157+
seen.add(s.platform);
158+
return true;
159+
});
154160
if (socialLinks.length) payload.socialLinks = socialLinks;
155161

156162
const team = data.teamMembers
@@ -305,8 +311,13 @@ export default function NewCampaignWizard() {
305311
setCompletedSteps(done);
306312
setActiveStep(landing);
307313
})
308-
.catch(() =>
309-
toast.error('Could not load your draft. Starting a fresh form.')
314+
.catch((err: unknown) =>
315+
toast.error(
316+
extractApiErrorMessage(
317+
err,
318+
'Could not load your draft. Starting a fresh form.'
319+
)
320+
)
310321
)
311322
.finally(() => setIsHydrating(false));
312323
}, []);
@@ -359,8 +370,13 @@ export default function NewCampaignWizard() {
359370
await persistDraft(data);
360371
markStepDone(activeStep);
361372
setActiveStep(s => s + 1);
362-
} catch {
363-
toast.error('Could not save your progress. Please try again.');
373+
} catch (err) {
374+
toast.error(
375+
extractApiErrorMessage(
376+
err,
377+
'Could not save your progress. Please try again.'
378+
)
379+
);
364380
} finally {
365381
setIsSaving(false);
366382
}
@@ -384,8 +400,13 @@ export default function NewCampaignWizard() {
384400
toast.success('Campaign submitted for review!');
385401
}
386402
router.push(`/me/crowdfunding/${id}`);
387-
} catch {
388-
toast.error('Failed to submit for review. Please try again.');
403+
} catch (err) {
404+
toast.error(
405+
extractApiErrorMessage(
406+
err,
407+
'Failed to submit for review. Please try again.'
408+
)
409+
);
389410
} finally {
390411
setIsSubmitting(false);
391412
}
@@ -440,6 +461,7 @@ export default function NewCampaignWizard() {
440461
activeStep={activeStep}
441462
completedSteps={completedSteps}
442463
campaignId={campaignId}
464+
onStepClick={setActiveStep}
443465
/>
444466

445467
<WizardHelpButton activeStep={activeStep} />

0 commit comments

Comments
 (0)