Skip to content

Commit 03e0cd5

Browse files
committed
Add HackBot chat widget with event cards, markdown, and layout integration
Chat widget with streaming responses, retry logic, and resize handle. Event cards (full for workshops/activities, compact for meals/general). Markdown text renderer, session-gated wrapper, cascading animations. Layout integration in (hackers) route group.
1 parent 2826a71 commit 03e0cd5

9 files changed

Lines changed: 1214 additions & 0 deletions

File tree

.claude/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git branch:*)",
5+
"Bash(git checkout:*)"
6+
]
7+
}
8+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { HiLocationMarker } from 'react-icons/hi';
5+
import { PiStarFourFill } from 'react-icons/pi';
6+
import { createUserToEvent } from '@actions/userToEvents/createUserToEvent';
7+
import { deleteUserToEvent } from '@actions/userToEvents/deleteUserToEvent';
8+
import type { HackbotEvent } from '@typeDefs/hackbot';
9+
10+
// Matches scheduleEventStyles.ts
11+
const TYPE_STYLE: Record<string, { bg: string; text: string; label: string }> =
12+
{
13+
WORKSHOPS: { bg: '#E9FBBA', text: '#1A3819', label: 'Workshop' },
14+
GENERAL: { bg: '#CCFFFE', text: '#003D3D', label: 'General' },
15+
ACTIVITIES: { bg: '#FFE2D5', text: '#52230C', label: 'Activity' },
16+
MEALS: { bg: '#FFE7B2', text: '#572700', label: 'Meal' },
17+
};
18+
19+
const TAG_LABEL: Record<string, string> = {
20+
developer: 'Dev',
21+
designer: 'Design',
22+
beginner: 'Beginner',
23+
pm: 'PM',
24+
other: 'Other',
25+
};
26+
27+
// Only show Add button for event types that make sense to save
28+
const ADDABLE_TYPES = new Set(['WORKSHOPS', 'ACTIVITIES']);
29+
30+
function maskIconStyle(src: string): React.CSSProperties {
31+
return {
32+
backgroundColor: 'currentColor',
33+
WebkitMaskImage: `url('${src}')`,
34+
WebkitMaskRepeat: 'no-repeat',
35+
WebkitMaskPosition: 'center',
36+
WebkitMaskSize: 'contain',
37+
maskImage: `url('${src}')`,
38+
maskRepeat: 'no-repeat',
39+
maskPosition: 'center',
40+
maskSize: 'contain',
41+
};
42+
}
43+
44+
export default function HackbotEventCard({
45+
event,
46+
userId,
47+
}: {
48+
event: HackbotEvent;
49+
userId: string;
50+
}) {
51+
const [added, setAdded] = useState(false);
52+
const [adding, setAdding] = useState(false);
53+
const [addError, setAddError] = useState(false);
54+
55+
const handleAdd = async () => {
56+
if (!userId || adding) return;
57+
setAdding(true);
58+
setAddError(false);
59+
try {
60+
if (added) {
61+
const result = await deleteUserToEvent({
62+
user_id: userId,
63+
event_id: event.id,
64+
});
65+
if (!result.ok) throw new Error('Failed to remove');
66+
setAdded(false);
67+
} else {
68+
const result = await createUserToEvent(userId, event.id);
69+
if (!result.ok) throw new Error(result.error ?? 'Failed');
70+
setAdded(true);
71+
}
72+
} catch {
73+
setAddError(true);
74+
}
75+
setAdding(false);
76+
};
77+
78+
const style = TYPE_STYLE[event.type ?? ''];
79+
const canAdd = userId && ADDABLE_TYPES.has(event.type ?? '');
80+
81+
return (
82+
<div
83+
className="rounded-xl border border-[#9EE7E5]/60 text-xs overflow-hidden animate-hackbot-slide-in"
84+
style={{ backgroundColor: style?.bg ?? '#FAFAFF' }}
85+
>
86+
{/* Event name — full width */}
87+
<div
88+
className="px-3 pt-2.5 pb-1"
89+
style={{ color: style?.text ?? '#005271' }}
90+
>
91+
<p className="font-semibold leading-snug">{event.name}</p>
92+
</div>
93+
94+
{/* Two-column body */}
95+
<div
96+
className="px-3 pb-1.5 flex gap-2 min-w-0"
97+
style={{ color: style?.text ?? '#003D3D' }}
98+
>
99+
{/* Left: datetime, location, tags */}
100+
<div className="flex-1 min-w-0 space-y-1 space-between">
101+
{event.start && (
102+
<p className="text-xs opacity-80">
103+
{event.start}
104+
{event.end ? ` - ${event.end}` : ''}
105+
</p>
106+
)}
107+
{event.location && (
108+
<p className="text-xs opacity-80 flex items-center gap-1">
109+
<HiLocationMarker className="shrink-0 w-3 h-3" />
110+
<span className="truncate">{event.location}</span>
111+
</p>
112+
)}
113+
{event.tags.length > 0 && (
114+
<div className="flex flex-wrap gap-1 pt-0.5">
115+
{event.tags.map((tag) => (
116+
<span
117+
key={tag}
118+
className="text-xs px-1.5 py-0.5 rounded-full font-medium"
119+
style={{
120+
backgroundColor: style ? style.text + '15' : '#00527115',
121+
color: style?.text ?? '#005271',
122+
border: `1px solid ${style?.text ?? '#005271'}25`,
123+
}}
124+
>
125+
{TAG_LABEL[tag] ?? tag}
126+
</span>
127+
))}
128+
</div>
129+
)}
130+
</div>
131+
132+
{/* Right: type badge, hosted by, recommended */}
133+
<div className="shrink-0 flex flex-col items-end gap-1.5 pt-0.5 space-between">
134+
{style && (
135+
<span
136+
className="text-xs px-1.5 py-0.5 rounded-full font-semibold uppercase tracking-wide border whitespace-nowrap"
137+
style={{
138+
borderColor: style.text + '40',
139+
color: style.text,
140+
}}
141+
>
142+
{style.label}
143+
</span>
144+
)}
145+
{event.host && (
146+
<p
147+
className="text-xs opacity-60 text-right leading-tight"
148+
// style={{ maxWidth: '100px' }}
149+
>
150+
{event.host}
151+
</p>
152+
)}
153+
{event.isRecommended && (
154+
<p
155+
className="text-xs font-semibold flex items-center gap-0.5 whitespace-nowrap"
156+
style={{ color: '#A07000' }}
157+
>
158+
<PiStarFourFill className="w-2.5 h-2.5 shrink-0" />
159+
Recommended
160+
</p>
161+
)}
162+
{canAdd && (
163+
<button
164+
type="button"
165+
onClick={handleAdd}
166+
disabled={adding}
167+
className="text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors disabled:cursor-default group/addbtn"
168+
style={
169+
added
170+
? {
171+
backgroundColor: '#005271',
172+
color: '#fff',
173+
borderColor: '#005271',
174+
}
175+
: addError
176+
? {
177+
backgroundColor: '#fee2e2',
178+
color: '#991b1b',
179+
borderColor: '#fca5a5',
180+
}
181+
: {
182+
backgroundColor: 'white',
183+
color: '#005271',
184+
borderColor: '#9EE7E5',
185+
}
186+
}
187+
>
188+
{adding ? (
189+
'…'
190+
) : addError ? (
191+
'Retry'
192+
) : added ? (
193+
<>
194+
<span className="inline-flex items-center gap-1 group-hover/addbtn:hidden">
195+
<span
196+
aria-hidden
197+
className="inline-block w-2.5 h-2.5"
198+
style={maskIconStyle('/icons/check.svg')}
199+
/>
200+
Added
201+
</span>
202+
<span
203+
className="hidden group-hover/addbtn:inline-flex items-center gap-1"
204+
style={{ color: '#fca5a5' }}
205+
>
206+
Remove
207+
</span>
208+
</>
209+
) : (
210+
<span className="inline-flex items-center gap-1">
211+
<span
212+
aria-hidden
213+
className="inline-block w-2.5 h-2.5"
214+
style={maskIconStyle('/icons/plus.svg')}
215+
/>
216+
Add
217+
</span>
218+
)}
219+
</button>
220+
)}
221+
</div>
222+
</div>
223+
</div>
224+
);
225+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client';
2+
3+
import { RxCross1 } from 'react-icons/rx';
4+
5+
export default function HackbotHeader({
6+
firstName,
7+
onClose,
8+
}: {
9+
firstName: string | undefined;
10+
onClose: () => void;
11+
}) {
12+
return (
13+
<header
14+
className="flex items-center justify-between px-4 py-3 shrink-0"
15+
style={{ backgroundColor: '#005271' }}
16+
>
17+
<div>
18+
<p className="text-base font-bold text-white">HackDavis Helper</p>
19+
<p className="text-xs text-[#9EE7E5]">
20+
{firstName
21+
? `Hi ${firstName}! Ask me anything about HackDavis.`
22+
: 'Ask me anything about HackDavis!'}
23+
</p>
24+
</div>
25+
<button
26+
type="button"
27+
onClick={onClose}
28+
className="h-7 w-7 rounded-full flex items-center justify-center text-white hover:bg-white/10 transition-colors"
29+
aria-label="Close chat"
30+
>
31+
<RxCross1 className="w-3.5 h-3.5" />
32+
</button>
33+
</header>
34+
);
35+
}

0 commit comments

Comments
 (0)