Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f33a407
fix(hackathons): single-column teams tab and primary-colored pager (#…
Benjtalkshow May 15, 2026
335758e
feat(hackathons): track-based prize structure + submission polish + t…
0xdevcollins May 16, 2026
1269fdf
Feat/submission visibility hidden until results (#566)
0xdevcollins May 16, 2026
80c7f2f
fix(hackathons): always open submissions in a new tab (#568)
Benjtalkshow May 18, 2026
1e3907e
merge: production into main
Benjtalkshow May 18, 2026
0b143cf
feat(judging): organizer dashboard — coverage, preview, per-track res…
0xdevcollins May 20, 2026
39ff6ee
feat(judging): unallocated partner funds become a warning, not a bloc…
0xdevcollins May 20, 2026
01705f8
merge: production into main (resolve AllocationPreviewCard conflict)
0xdevcollins May 21, 2026
c13e9ea
fix(rewards): show track winners on the organizer rewards page (#576)
0xdevcollins May 21, 2026
6101e01
fix(rewards): polish publish-wizard preview to industry-standard layo…
0xdevcollins May 21, 2026
7e6b842
feat(hackathons): add judging dataset to export dropdown (#577)
Benjtalkshow May 21, 2026
e32eae4
merge: production into main (resolve WinnersGrid conflict)
0xdevcollins May 21, 2026
6ecdad4
fix(rewards): match track winners by submissionId, not participantId …
0xdevcollins May 21, 2026
3fb4121
fix(auth): send absolute callbackURL on Google sign-up (#584)
0xdevcollins May 21, 2026
a5db3c4
fix(submissions): unbreak submission detail page + publish hackathon …
Benjtalkshow May 22, 2026
72f76fc
fix(blog): drop duplicate h1 from hackathon winners post (#588)
Benjtalkshow May 22, 2026
db18f9f
merge: production into main
Benjtalkshow May 22, 2026
781f15f
feat(didit): drive verification UI from /didit/status, fix In Review …
0xdevcollins May 25, 2026
e083d17
Feat/didit verification status frontend (#592)
0xdevcollins May 25, 2026
fd2b990
fix: align request payloads with backend DTOs ahead of forbidNonWhite…
0xdevcollins May 28, 2026
52be58c
Feat/crowdfunding (#640)
0xdevcollins Jun 24, 2026
2cb7127
Revert "Feat/crowdfunding (#640)" (#641)
0xdevcollins Jun 24, 2026
ef423a2
Enhance bounty features with new UI, data layers, and crowdfunding su…
0xdevcollins Jun 27, 2026
a817075
merge: resolve package-lock.json conflict from production into main
0xdevcollins Jun 27, 2026
7f6857a
Integrate treasury management, refactor bounty UI, and enhance crowdf…
0xdevcollins Jun 30, 2026
2de3ac6
merge: resolve origin/production into main (#666)
0xdevcollins Jun 30, 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
89 changes: 89 additions & 0 deletions components/ai/AiAssumptionsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client';

import { useState } from 'react';
import { Sparkles, X } from 'lucide-react';

import { BoundlessButton } from '@/components/buttons';

export interface AiAssumption {
section: string;
field: string;
note: string;
}

interface AiAssumptionsBannerProps {
assumptions: AiAssumption[];
/** Jump to the wizard step that owns an assumption so the organizer can edit. */
onReview?: (section: string) => void;
className?: string;
}

/**
* Shows the non-obvious choices an AI draft made ("Assumed a single winner…")
* so the organizer can see and correct every guess. Shared by the bounty and
* hackathon review steps. Dismissible; renders nothing when there's nothing to
* surface.
*/
export default function AiAssumptionsBanner({
assumptions,
onReview,
className,
}: AiAssumptionsBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed || !assumptions || assumptions.length === 0) return null;

return (
<div
className={[
'border-primary/30 from-primary/10 rounded-xl border bg-gradient-to-r to-transparent p-4',
className ?? '',
].join(' ')}
>
<div className='flex items-start justify-between gap-3'>
<div className='flex items-start gap-3'>
<span className='bg-primary/15 text-primary mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg'>
<Sparkles className='h-4 w-4' />
</span>
<div>
<p className='text-sm font-semibold text-white'>
A few things the AI assumed
</p>
<p className='text-xs text-gray-400'>
Review these guesses and correct anything that doesn&apos;t match
what you meant.
</p>
</div>
</div>
<button
type='button'
aria-label='Dismiss'
onClick={() => setDismissed(true)}
className='text-gray-500 transition-colors hover:text-gray-300'
>
<X className='h-4 w-4' />
</button>
</div>

<ul className='mt-3 space-y-2'>
{assumptions.map((a, i) => (
<li
key={`${a.section}-${a.field}-${i}`}
className='flex items-center justify-between gap-3 rounded-lg bg-black/20 px-3 py-2'
>
<span className='text-sm text-gray-300'>{a.note}</span>
{onReview && (
<BoundlessButton
type='button'
variant='outline'
size='sm'
onClick={() => onReview(a.section)}
>
Review
</BoundlessButton>
)}
</li>
))}
</ul>
</div>
);
}
52 changes: 52 additions & 0 deletions components/ai/AiBriefPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';

import { useState } from 'react';
import { ChevronDown, FileText } from 'lucide-react';

interface AiBriefPanelProps {
/** The brief that produced the AI draft (may include folded-in clarify answers). */
brief?: string | null;
className?: string;
}

/**
* Collapsible "Your brief" panel shown beside an AI-generated draft on the review
* step, so the organizer can compare what they asked for against what the AI
* produced. Renders nothing for manually-created drafts. Shared by both wizards.
*/
export default function AiBriefPanel({ brief, className }: AiBriefPanelProps) {
const [open, setOpen] = useState(false);
if (!brief || brief.trim() === '') return null;

return (
<div
className={[
'rounded-xl border border-zinc-800 bg-zinc-900/40',
className ?? '',
].join(' ')}
>
<button
type='button'
onClick={() => setOpen(o => !o)}
className='flex w-full items-center justify-between gap-2 px-4 py-3 text-left'
aria-expanded={open}
>
<span className='flex items-center gap-2 text-sm font-medium text-white'>
<FileText className='h-4 w-4 text-gray-400' />
Your brief
</span>
<ChevronDown
className={[
'h-4 w-4 text-gray-500 transition-transform',
open ? 'rotate-180' : '',
].join(' ')}
/>
</button>
{open && (
<div className='border-t border-zinc-800 px-4 py-3'>
<p className='text-sm whitespace-pre-wrap text-gray-300'>{brief}</p>
</div>
)}
</div>
);
}
130 changes: 130 additions & 0 deletions components/ai/AiClarifyQuestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
'use client';

import { useState } from 'react';
import { Sparkles } from 'lucide-react';

import { BoundlessButton } from '@/components/buttons';

export interface ClarifyQuestionOption {
value: string;
label: string;
}
export interface ClarifyQuestionItem {
id: string;
question: string;
options: ClarifyQuestionOption[];
}

/** A picked answer, ready to fold into the brief. */
export interface ClarifyAnswer {
question: string;
label: string;
}

interface AiClarifyQuestionsProps {
questions: ClarifyQuestionItem[];
/** Continue to drafting with the chosen answers (skipped questions omitted). */
onSubmit: (answers: ClarifyAnswer[]) => void;
/** Draft now without answering (the AI picks sensible defaults). */
onSkip: () => void;
isSubmitting?: boolean;
}

/**
* Adaptive clarify step shared by the bounty + hackathon generate dialogs.
* Shows 1-3 chip questions when the brief was too thin; answers are folded back
* into the brief before drafting. Answering is optional — Skip drafts straight
* away.
*/
export default function AiClarifyQuestions({
questions,
onSubmit,
onSkip,
isSubmitting = false,
}: AiClarifyQuestionsProps) {
const [selected, setSelected] = useState<Record<string, string>>({});

const handleContinue = () => {
const answers: ClarifyAnswer[] = questions
.filter(q => selected[q.id])
.map(q => ({
question: q.question,
label:
q.options.find(o => o.value === selected[q.id])?.label ??
selected[q.id],
}));
onSubmit(answers);
};

return (
<div className='space-y-4'>
<div className='flex items-start gap-3'>
<span className='bg-primary/15 text-primary mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg'>
<Sparkles className='h-4 w-4' />
</span>
<p className='text-sm text-gray-300'>
A couple of quick questions so the draft matches what you have in
mind. Answer what you can — you can skip the rest.
</p>
</div>

<div className='space-y-4'>
{questions.map(q => (
<div key={q.id} className='space-y-2'>
<p className='text-sm font-medium text-white'>{q.question}</p>
<div className='flex flex-wrap gap-2'>
{q.options.map(o => {
const on = selected[q.id] === o.value;
return (
<button
key={o.value}
type='button'
onClick={() =>
setSelected(prev =>
prev[q.id] === o.value
? (() => {
const next = { ...prev };
delete next[q.id];
return next;
})()
: { ...prev, [q.id]: o.value }
)
}
className={[
'rounded-full border px-3 py-1 text-xs transition-colors',
on
? 'border-primary bg-primary/15 text-primary'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200',
].join(' ')}
>
{o.label}
</button>
);
})}
</div>
</div>
))}
</div>

<div className='flex justify-between gap-2 pt-2'>
<BoundlessButton
type='button'
variant='outline'
onClick={onSkip}
disabled={isSubmitting}
>
Skip
</BoundlessButton>
<BoundlessButton
type='button'
loading={isSubmitting}
icon={<Sparkles className='h-4 w-4' />}
iconPosition='left'
onClick={handleContinue}
>
Generate draft
</BoundlessButton>
</div>
</div>
);
}
71 changes: 71 additions & 0 deletions components/ai/AiExampleReference.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use client';

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';

export interface ExampleItem {
id: string;
label: string;
/** The text fed to the model as a style reference when this item is picked. */
example: string;
}

interface AiExampleReferenceProps {
items: ExampleItem[];
/** Selected item id, or null for none. */
value: string | null;
onChange: (id: string | null) => void;
/** e.g. "bounty" | "hackathon". */
noun: string;
}

const NONE = '__none__';

/**
* Optional "use a past one as a style reference" picker for the Generate
* dialogs. The chosen item's gist is passed to the model as `examples[]` so the
* draft mirrors the organizer's house style. Renders nothing when there's
* nothing to reference. Shared by both wizards.
*/
export default function AiExampleReference({
items,
value,
onChange,
noun,
}: AiExampleReferenceProps) {
if (items.length === 0) return null;

return (
<div className='space-y-1.5'>
<p className='text-sm font-medium'>
Match a past {noun}{' '}
<span className='text-muted-foreground font-normal'>(optional)</span>
</p>
<Select
value={value ?? NONE}
onValueChange={v => onChange(v === NONE ? null : v)}
>
<SelectTrigger className='h-10 rounded-lg border-zinc-800 bg-zinc-900/50 text-white'>
<SelectValue placeholder={`Pick a past ${noun} to match its style`} />
</SelectTrigger>
<SelectContent className='border-zinc-800 bg-zinc-950 text-white'>
<SelectItem value={NONE}>None</SelectItem>
{items.map(item => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
We&apos;ll use its style as a reference — your brief still drives the
content.
</p>
</div>
);
}
Loading
Loading