1+ /** @jsxImportSource theme-ui */
2+ import Meta from '@hackclub/meta'
3+ import Head from 'next/head'
4+ import { Box , Heading , Text , Link as ThemeLink , Input } from 'theme-ui'
5+ import { useState , useMemo , useEffect } from 'react'
6+ import Footer from '../components/footer'
7+ import ForceTheme from '../components/force-theme'
8+ import Nav from '../components/nav'
9+
10+ const StatusBadge = ( { status, deadline } ) => {
11+ const isEndingSoon =
12+ status === 'active' &&
13+ deadline &&
14+ new Date ( deadline ) - new Date ( ) < 7 * 24 * 60 * 60 * 1000
15+
16+ const label = isEndingSoon ? 'Ending Soon' : status
17+ const colors = {
18+ active : { bg : '#d4f5e0' , color : '#1a7f3c' } ,
19+ 'ending soon' : { bg : '#fff3cd' , color : '#856404' } ,
20+ draft : { bg : '#e2e8f0' , color : '#475569' } ,
21+ ended : { bg : '#fee2e2' , color : '#991b1b' }
22+ }
23+ const style = colors [ label . toLowerCase ( ) ] || colors [ 'draft' ]
24+
25+ return (
26+ < Box
27+ sx = { {
28+ display : 'inline-block' ,
29+ px : 2 ,
30+ py : '2px' ,
31+ borderRadius : '999px' ,
32+ fontSize : '0.7rem' ,
33+ fontWeight : 700 ,
34+ textTransform : 'uppercase' ,
35+ letterSpacing : '0.05em' ,
36+ bg : style . bg ,
37+ color : style . color
38+ } }
39+ >
40+ { label }
41+ </ Box >
42+ )
43+ }
44+
45+ const EventCard = ( { program } ) => (
46+ < Box
47+ as = "a"
48+ href = { program . website || program . slack || 'https://ysws.hackclub.com' }
49+ target = "_blank"
50+ rel = "noopener noreferrer"
51+ sx = { {
52+ display : 'flex' ,
53+ flexDirection : 'column' ,
54+ gap : 2 ,
55+ p : 3 ,
56+ bg : 'white' ,
57+ borderRadius : '12px' ,
58+ border : '1px solid' ,
59+ borderColor : 'smoke' ,
60+ borderTop : '4px solid' ,
61+ borderTopColor : 'primary' ,
62+ textDecoration : 'none' ,
63+ transition : 'all 0.2s ease-in-out' ,
64+ cursor : 'pointer' ,
65+ '&:hover' : {
66+ transform : 'translateY(-4px)' ,
67+ boxShadow : '0 8px 24px rgba(236, 55, 80, 0.15)' ,
68+ borderTopColor : 'orange'
69+ }
70+ } }
71+ >
72+ < Box sx = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'flex-start' , gap : 2 } } >
73+ < Text sx = { { fontWeight : 800 , fontSize : '1rem' , color : 'primary' , lineHeight : 1.3 } } >
74+ { program . name }
75+ </ Text >
76+ < StatusBadge status = { program . status } deadline = { program . deadline } />
77+ </ Box >
78+ < Text sx = { { fontSize : '0.85rem' , color : 'slate' , lineHeight : 1.5 , flexGrow : 1 } } >
79+ { program . description }
80+ </ Text >
81+ { program . slackChannel && (
82+ < Text sx = { { fontSize : '0.75rem' , color : 'muted' , fontWeight : 600 , fontFamily : 'monospace' } } >
83+ { program . slackChannel }
84+ </ Text >
85+ ) }
86+ { program . deadline && program . status === 'active' && (
87+ < Text sx = { { fontSize : '0.72rem' , color : 'muted' , fontWeight : 500 } } >
88+ Ends { new Date ( program . deadline ) . toLocaleDateString ( 'en-US' , { month : 'short' , day : 'numeric' , year : 'numeric' } ) }
89+ </ Text >
90+ ) }
91+ </ Box >
92+ )
93+
94+ const FilterButton = ( { active, onClick, children } ) => (
95+ < Box
96+ as = "button"
97+ type = "button"
98+ onClick = { onClick }
99+ aria-pressed = { active }
100+ sx = { {
101+ px : 4 ,
102+ py : 2 ,
103+ borderRadius : '999px' ,
104+ border : '2px solid' ,
105+ borderColor : active ? 'primary' : 'smoke' ,
106+ bg : active ? 'primary' : 'white' ,
107+ color : active ? 'white' : 'slate' ,
108+ fontWeight : 700 ,
109+ fontSize : '0.9rem' ,
110+ cursor : 'pointer' ,
111+ fontFamily : 'inherit' ,
112+ transition : 'all 0.15s ease' ,
113+ '&:hover' : {
114+ borderColor : 'primary' ,
115+ color : active ? 'white' : 'primary'
116+ }
117+ } }
118+ >
119+ { children }
120+ </ Box >
121+ )
122+
123+ const EventsPage = ( ) => {
124+ const [ search , setSearch ] = useState ( '' )
125+ const [ filter , setFilter ] = useState ( 'active' )
126+ const [ programs , setPrograms ] = useState ( [ ] )
127+ const [ loading , setLoading ] = useState ( true )
128+ const [ error , setError ] = useState ( false )
129+
130+ useEffect ( ( ) => {
131+ fetch ( '/api/ysws' )
132+ . then ( ( r ) => r . json ( ) )
133+ . then ( ( data ) => {
134+ const all = [
135+ ...( data . limitedTime || [ ] ) ,
136+ ...( data . indefinite || [ ] ) ,
137+ ...( data . recentlyEnded || [ ] ) ,
138+ ...( data . drafts || [ ] )
139+ ]
140+ setPrograms ( all )
141+ setLoading ( false )
142+ } )
143+ . catch ( ( ) => {
144+ setError ( true )
145+ setLoading ( false )
146+ } )
147+ } , [ ] )
148+
149+ const counts = useMemo ( ( ) => ( {
150+ active : programs . filter ( ( p ) => p . status === 'active' ) . length ,
151+ ended : programs . filter ( ( p ) => p . status === 'ended' ) . length ,
152+ draft : programs . filter ( ( p ) => p . status === 'draft' ) . length ,
153+ all : programs . length
154+ } ) , [ programs ] )
155+
156+ const filtered = useMemo ( ( ) => {
157+ return programs . filter ( ( p ) => {
158+ const matchesSearch =
159+ p . name ?. toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ||
160+ p . description ?. toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ||
161+ p . slackChannel ?. toLowerCase ( ) . includes ( search . toLowerCase ( ) )
162+ const matchesFilter = filter === 'all' || p . status === filter
163+ return matchesSearch && matchesFilter
164+ } )
165+ } , [ programs , search , filter ] )
166+
167+ return (
168+ < Box
169+ sx = { {
170+ backgroundImage : 'url(/pattern.svg)' ,
171+ backgroundRepeat : 'repeat' ,
172+ backgroundAttachment : 'fixed' ,
173+ minHeight : '100vh' ,
174+ backgroundColor : 'snow'
175+ } }
176+ >
177+ < Meta
178+ as = { Head }
179+ name = "YSWS Programs – Hack Club Slack"
180+ description = "Browse all Hack Club You Ship We Ship programs. Build something amazing and get rewarded!"
181+ />
182+ < ForceTheme theme = "light" />
183+ < Nav />
184+
185+ { /* Hero */ }
186+ < Box
187+ sx = { {
188+ backgroundImage : ( t ) => t . util . gx ( 'orange' , 'red' ) ,
189+ pt : [ '7rem' , '8rem' ] ,
190+ pb : [ '3rem' , '4rem' ] ,
191+ px : [ '1.5rem' , '3rem' ] ,
192+ textAlign : 'center'
193+ } }
194+ >
195+ < Heading
196+ as = "h1"
197+ sx = { {
198+ fontSize : [ '2.5rem' , '4rem' ] ,
199+ color : 'white' ,
200+ fontWeight : 800 ,
201+ mb : 2 ,
202+ textShadow : '0 2px 12px rgba(0,0,0,0.2)'
203+ } }
204+ >
205+ You Ship, We Ship
206+ </ Heading >
207+ < Text sx = { { color : 'white' , fontSize : [ '1rem' , '1.25rem' ] , opacity : 0.9 , mb : 3 } } >
208+ Build something awesome. Hack Club ships you something epic in return.
209+ </ Text >
210+ { ! loading && ! error && (
211+ < Text sx = { { color : 'white' , fontSize : '0.95rem' , opacity : 0.75 } } >
212+ { counts . active } active program{ counts . active !== 1 ? 's' : '' } · { counts . all } total
213+ </ Text >
214+ ) }
215+ </ Box >
216+
217+ { /* Content */ }
218+ < Box sx = { { maxWidth : '1200px' , mx : 'auto' , px : [ '1.5rem' , '3rem' ] , py : [ '1.5rem' , '2rem' ] } } >
219+
220+ { /* Search + Filter */ }
221+ < Box
222+ sx = { {
223+ bg : 'white' ,
224+ borderRadius : '16px' ,
225+ p : [ '1.25rem' , '1.75rem' ] ,
226+ boxShadow : 'card' ,
227+ border : '1px solid' ,
228+ borderColor : 'smoke' ,
229+ mb : [ '1.5rem' , '2rem' ]
230+ } }
231+ >
232+ < Input
233+ placeholder = "Search programs..."
234+ value = { search }
235+ onChange = { ( e ) => setSearch ( e . target . value ) }
236+ sx = { {
237+ mb : 3 ,
238+ fontSize : '1rem' ,
239+ borderRadius : '8px' ,
240+ border : '2px solid' ,
241+ borderColor : 'smoke' ,
242+ px : 3 ,
243+ py : 2 ,
244+ fontFamily : 'inherit' ,
245+ '&:focus' : { borderColor : 'primary' , outline : 'none' }
246+ } }
247+ />
248+ < Box sx = { { display : 'flex' , gap : 2 , flexWrap : 'wrap' } } >
249+ < FilterButton active = { filter === 'active' } onClick = { ( ) => setFilter ( 'active' ) } >
250+ Active ({ counts . active } )
251+ </ FilterButton >
252+ < FilterButton active = { filter === 'all' } onClick = { ( ) => setFilter ( 'all' ) } >
253+ All ({ counts . all } )
254+ </ FilterButton >
255+ < FilterButton active = { filter === 'ended' } onClick = { ( ) => setFilter ( 'ended' ) } >
256+ Ended ({ counts . ended } )
257+ </ FilterButton >
258+ < FilterButton active = { filter === 'draft' } onClick = { ( ) => setFilter ( 'draft' ) } >
259+ Draft ({ counts . draft } )
260+ </ FilterButton >
261+ </ Box >
262+ </ Box >
263+
264+ { loading && (
265+ < Box sx = { { textAlign : 'center' , py : '4rem' , color : 'muted' } } >
266+ < Text sx = { { fontSize : '1rem' , fontWeight : 600 } } > Loading programs...</ Text >
267+ </ Box >
268+ ) }
269+
270+ { error && (
271+ < Box sx = { { textAlign : 'center' , py : '4rem' , color : 'muted' } } >
272+ < Text sx = { { fontSize : '1rem' , fontWeight : 600 } } >
273+ Couldn't load programs. Try again later.
274+ </ Text >
275+ </ Box >
276+ ) }
277+
278+ { ! loading && ! error && (
279+ < >
280+ < Text sx = { { color : 'muted' , fontSize : '0.9rem' , mb : 3 , fontWeight : 500 } } >
281+ { filtered . length === 0
282+ ? 'No programs found'
283+ : `Showing ${ filtered . length } program${ filtered . length !== 1 ? 's' : '' } ` }
284+ </ Text >
285+
286+ { filtered . length > 0 ? (
287+ < Box
288+ sx = { {
289+ display : 'grid' ,
290+ gridTemplateColumns : [ '1fr' , 'repeat(2, 1fr)' , 'repeat(3, 1fr)' ] ,
291+ gap : [ '0.75rem' , '1rem' ]
292+ } }
293+ >
294+ { filtered . map ( ( program , i ) => (
295+ < EventCard key = { program . name + i } program = { program } />
296+ ) ) }
297+ </ Box >
298+ ) : (
299+ < Box sx = { { textAlign : 'center' , py : '4rem' , color : 'muted' } } >
300+ < Text sx = { { fontSize : '1.15rem' , fontWeight : 600 } } >
301+ No programs match "{ search } "
302+ </ Text >
303+ < Text sx = { { fontSize : '0.95rem' , mt : 1 } } > Try a different search term</ Text >
304+ </ Box >
305+ ) }
306+ </ >
307+ ) }
308+
309+ < Box sx = { { textAlign : 'center' , mt : [ '3rem' , '4rem' ] , mb : '2rem' , display : 'flex' , gap : 4 , justifyContent : 'center' , flexWrap : 'wrap' } } >
310+ < ThemeLink
311+ href = "https://ysws.hackclub.com"
312+ target = "_blank"
313+ rel = "noopener noreferrer"
314+ sx = { { color : 'primary' , fontWeight : 700 , fontSize : '1rem' , textDecoration : 'none' , '&:hover' : { textDecoration : 'underline' } } }
315+ >
316+ View full YSWS catalog →
317+ </ ThemeLink >
318+ < ThemeLink
319+ href = "/"
320+ sx = { { color : 'primary' , fontWeight : 700 , fontSize : '1rem' , textDecoration : 'none' , '&:hover' : { textDecoration : 'underline' } } }
321+ >
322+ Back to Hack Club Slack
323+ </ ThemeLink >
324+ </ Box >
325+ </ Box >
326+
327+ < Footer />
328+ </ Box >
329+ )
330+ }
331+
332+ export default EventsPage
0 commit comments