Skip to content

Commit 2ca0987

Browse files
committed
feat: implement AI-assisted draft generation for hackathons and bounties
1 parent 53daf07 commit 2ca0987

31 files changed

Lines changed: 4925 additions & 522 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Sparkles, X } from 'lucide-react';
5+
6+
import { BoundlessButton } from '@/components/buttons';
7+
8+
export interface AiAssumption {
9+
section: string;
10+
field: string;
11+
note: string;
12+
}
13+
14+
interface AiAssumptionsBannerProps {
15+
assumptions: AiAssumption[];
16+
/** Jump to the wizard step that owns an assumption so the organizer can edit. */
17+
onReview?: (section: string) => void;
18+
className?: string;
19+
}
20+
21+
/**
22+
* Shows the non-obvious choices an AI draft made ("Assumed a single winner…")
23+
* so the organizer can see and correct every guess. Shared by the bounty and
24+
* hackathon review steps. Dismissible; renders nothing when there's nothing to
25+
* surface.
26+
*/
27+
export default function AiAssumptionsBanner({
28+
assumptions,
29+
onReview,
30+
className,
31+
}: AiAssumptionsBannerProps) {
32+
const [dismissed, setDismissed] = useState(false);
33+
if (dismissed || !assumptions || assumptions.length === 0) return null;
34+
35+
return (
36+
<div
37+
className={[
38+
'border-primary/30 from-primary/10 rounded-xl border bg-gradient-to-r to-transparent p-4',
39+
className ?? '',
40+
].join(' ')}
41+
>
42+
<div className='flex items-start justify-between gap-3'>
43+
<div className='flex items-start gap-3'>
44+
<span className='bg-primary/15 text-primary mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg'>
45+
<Sparkles className='h-4 w-4' />
46+
</span>
47+
<div>
48+
<p className='text-sm font-semibold text-white'>
49+
A few things the AI assumed
50+
</p>
51+
<p className='text-xs text-gray-400'>
52+
Review these guesses and correct anything that doesn&apos;t match
53+
what you meant.
54+
</p>
55+
</div>
56+
</div>
57+
<button
58+
type='button'
59+
aria-label='Dismiss'
60+
onClick={() => setDismissed(true)}
61+
className='text-gray-500 transition-colors hover:text-gray-300'
62+
>
63+
<X className='h-4 w-4' />
64+
</button>
65+
</div>
66+
67+
<ul className='mt-3 space-y-2'>
68+
{assumptions.map((a, i) => (
69+
<li
70+
key={`${a.section}-${a.field}-${i}`}
71+
className='flex items-center justify-between gap-3 rounded-lg bg-black/20 px-3 py-2'
72+
>
73+
<span className='text-sm text-gray-300'>{a.note}</span>
74+
{onReview && (
75+
<BoundlessButton
76+
type='button'
77+
variant='outline'
78+
size='sm'
79+
onClick={() => onReview(a.section)}
80+
>
81+
Review
82+
</BoundlessButton>
83+
)}
84+
</li>
85+
))}
86+
</ul>
87+
</div>
88+
);
89+
}

components/ai/AiBriefPanel.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { ChevronDown, FileText } from 'lucide-react';
5+
6+
interface AiBriefPanelProps {
7+
/** The brief that produced the AI draft (may include folded-in clarify answers). */
8+
brief?: string | null;
9+
className?: string;
10+
}
11+
12+
/**
13+
* Collapsible "Your brief" panel shown beside an AI-generated draft on the review
14+
* step, so the organizer can compare what they asked for against what the AI
15+
* produced. Renders nothing for manually-created drafts. Shared by both wizards.
16+
*/
17+
export default function AiBriefPanel({ brief, className }: AiBriefPanelProps) {
18+
const [open, setOpen] = useState(false);
19+
if (!brief || brief.trim() === '') return null;
20+
21+
return (
22+
<div
23+
className={[
24+
'rounded-xl border border-zinc-800 bg-zinc-900/40',
25+
className ?? '',
26+
].join(' ')}
27+
>
28+
<button
29+
type='button'
30+
onClick={() => setOpen(o => !o)}
31+
className='flex w-full items-center justify-between gap-2 px-4 py-3 text-left'
32+
aria-expanded={open}
33+
>
34+
<span className='flex items-center gap-2 text-sm font-medium text-white'>
35+
<FileText className='h-4 w-4 text-gray-400' />
36+
Your brief
37+
</span>
38+
<ChevronDown
39+
className={[
40+
'h-4 w-4 text-gray-500 transition-transform',
41+
open ? 'rotate-180' : '',
42+
].join(' ')}
43+
/>
44+
</button>
45+
{open && (
46+
<div className='border-t border-zinc-800 px-4 py-3'>
47+
<p className='text-sm whitespace-pre-wrap text-gray-300'>{brief}</p>
48+
</div>
49+
)}
50+
</div>
51+
);
52+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Sparkles } from 'lucide-react';
5+
6+
import { BoundlessButton } from '@/components/buttons';
7+
8+
export interface ClarifyQuestionOption {
9+
value: string;
10+
label: string;
11+
}
12+
export interface ClarifyQuestionItem {
13+
id: string;
14+
question: string;
15+
options: ClarifyQuestionOption[];
16+
}
17+
18+
/** A picked answer, ready to fold into the brief. */
19+
export interface ClarifyAnswer {
20+
question: string;
21+
label: string;
22+
}
23+
24+
interface AiClarifyQuestionsProps {
25+
questions: ClarifyQuestionItem[];
26+
/** Continue to drafting with the chosen answers (skipped questions omitted). */
27+
onSubmit: (answers: ClarifyAnswer[]) => void;
28+
/** Draft now without answering (the AI picks sensible defaults). */
29+
onSkip: () => void;
30+
isSubmitting?: boolean;
31+
}
32+
33+
/**
34+
* Adaptive clarify step shared by the bounty + hackathon generate dialogs.
35+
* Shows 1-3 chip questions when the brief was too thin; answers are folded back
36+
* into the brief before drafting. Answering is optional — Skip drafts straight
37+
* away.
38+
*/
39+
export default function AiClarifyQuestions({
40+
questions,
41+
onSubmit,
42+
onSkip,
43+
isSubmitting = false,
44+
}: AiClarifyQuestionsProps) {
45+
const [selected, setSelected] = useState<Record<string, string>>({});
46+
47+
const handleContinue = () => {
48+
const answers: ClarifyAnswer[] = questions
49+
.filter(q => selected[q.id])
50+
.map(q => ({
51+
question: q.question,
52+
label:
53+
q.options.find(o => o.value === selected[q.id])?.label ??
54+
selected[q.id],
55+
}));
56+
onSubmit(answers);
57+
};
58+
59+
return (
60+
<div className='space-y-4'>
61+
<div className='flex items-start gap-3'>
62+
<span className='bg-primary/15 text-primary mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg'>
63+
<Sparkles className='h-4 w-4' />
64+
</span>
65+
<p className='text-sm text-gray-300'>
66+
A couple of quick questions so the draft matches what you have in
67+
mind. Answer what you can — you can skip the rest.
68+
</p>
69+
</div>
70+
71+
<div className='space-y-4'>
72+
{questions.map(q => (
73+
<div key={q.id} className='space-y-2'>
74+
<p className='text-sm font-medium text-white'>{q.question}</p>
75+
<div className='flex flex-wrap gap-2'>
76+
{q.options.map(o => {
77+
const on = selected[q.id] === o.value;
78+
return (
79+
<button
80+
key={o.value}
81+
type='button'
82+
onClick={() =>
83+
setSelected(prev =>
84+
prev[q.id] === o.value
85+
? (() => {
86+
const next = { ...prev };
87+
delete next[q.id];
88+
return next;
89+
})()
90+
: { ...prev, [q.id]: o.value }
91+
)
92+
}
93+
className={[
94+
'rounded-full border px-3 py-1 text-xs transition-colors',
95+
on
96+
? 'border-primary bg-primary/15 text-primary'
97+
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200',
98+
].join(' ')}
99+
>
100+
{o.label}
101+
</button>
102+
);
103+
})}
104+
</div>
105+
</div>
106+
))}
107+
</div>
108+
109+
<div className='flex justify-between gap-2 pt-2'>
110+
<BoundlessButton
111+
type='button'
112+
variant='outline'
113+
onClick={onSkip}
114+
disabled={isSubmitting}
115+
>
116+
Skip
117+
</BoundlessButton>
118+
<BoundlessButton
119+
type='button'
120+
loading={isSubmitting}
121+
icon={<Sparkles className='h-4 w-4' />}
122+
iconPosition='left'
123+
onClick={handleContinue}
124+
>
125+
Generate draft
126+
</BoundlessButton>
127+
</div>
128+
</div>
129+
);
130+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use client';
2+
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from '@/components/ui/select';
10+
11+
export interface ExampleItem {
12+
id: string;
13+
label: string;
14+
/** The text fed to the model as a style reference when this item is picked. */
15+
example: string;
16+
}
17+
18+
interface AiExampleReferenceProps {
19+
items: ExampleItem[];
20+
/** Selected item id, or null for none. */
21+
value: string | null;
22+
onChange: (id: string | null) => void;
23+
/** e.g. "bounty" | "hackathon". */
24+
noun: string;
25+
}
26+
27+
const NONE = '__none__';
28+
29+
/**
30+
* Optional "use a past one as a style reference" picker for the Generate
31+
* dialogs. The chosen item's gist is passed to the model as `examples[]` so the
32+
* draft mirrors the organizer's house style. Renders nothing when there's
33+
* nothing to reference. Shared by both wizards.
34+
*/
35+
export default function AiExampleReference({
36+
items,
37+
value,
38+
onChange,
39+
noun,
40+
}: AiExampleReferenceProps) {
41+
if (items.length === 0) return null;
42+
43+
return (
44+
<div className='space-y-1.5'>
45+
<p className='text-sm font-medium'>
46+
Match a past {noun}{' '}
47+
<span className='text-muted-foreground font-normal'>(optional)</span>
48+
</p>
49+
<Select
50+
value={value ?? NONE}
51+
onValueChange={v => onChange(v === NONE ? null : v)}
52+
>
53+
<SelectTrigger className='h-10 rounded-lg border-zinc-800 bg-zinc-900/50 text-white'>
54+
<SelectValue placeholder={`Pick a past ${noun} to match its style`} />
55+
</SelectTrigger>
56+
<SelectContent className='border-zinc-800 bg-zinc-950 text-white'>
57+
<SelectItem value={NONE}>None</SelectItem>
58+
{items.map(item => (
59+
<SelectItem key={item.id} value={item.id}>
60+
{item.label}
61+
</SelectItem>
62+
))}
63+
</SelectContent>
64+
</Select>
65+
<p className='text-muted-foreground text-xs'>
66+
We&apos;ll use its style as a reference — your brief still drives the
67+
content.
68+
</p>
69+
</div>
70+
);
71+
}

0 commit comments

Comments
 (0)