|
1 | | -import { Routes, Route, Navigate, useNavigate } from "react-router-dom"; |
| 1 | +import { |
| 2 | + Routes, |
| 3 | + Route, |
| 4 | + Navigate, |
| 5 | + useNavigate, |
| 6 | + useParams, |
| 7 | +} from "react-router-dom"; |
2 | 8 | import { Authenticated, Unauthenticated, AuthLoading } from "convex/react"; |
3 | | -import type { ReactNode } from "react"; |
| 9 | +import { useEffect, useRef, useState, type ReactNode } from "react"; |
4 | 10 | import "./App.css"; |
5 | 11 | import Landing from "./pages/Landing"; |
6 | 12 | import { Home } from "./pages/Home"; |
7 | 13 | import { Bookmarks } from "./pages/Bookmarks"; |
8 | 14 | import { Subscriptions } from "./pages/Subscriptions"; |
9 | 15 | import { Org } from "./pages/Org"; |
10 | 16 | import { Profile } from "./pages/profile"; |
| 17 | +import { Search } from "./pages/Search"; |
11 | 18 | import type { SideBarItemId } from "@app/ui"; |
12 | 19 | import DesignSystem from "./pages/DesignSystem"; |
13 | 20 | import { |
14 | 21 | SAMPLE_POSTS, |
15 | 22 | SAMPLE_RSVP_GROUPS, |
16 | 23 | SAMPLE_CLUBS, |
17 | 24 | } from "./data/sampleHome"; |
| 25 | +import { SAMPLE_BOOKMARKED_POSTS } from "./data/sampleBookmarks"; |
| 26 | +import { SAMPLE_ORGS } from "./data/sampleOrgs"; |
| 27 | +import { SAMPLE_SUBSCRIPTIONS } from "./data/sampleSubscriptions"; |
| 28 | +import type { Club, Organization } from "@app/ui"; |
18 | 29 |
|
19 | 30 | /** |
20 | 31 | * Maps a SideBar nav id to its route path. |
@@ -52,47 +63,269 @@ function ProtectedRoute({ children }: { children: ReactNode }) { |
52 | 63 | // These small wrappers wire the router-aware values in once so the page files |
53 | 64 | // stay framework-agnostic. |
54 | 65 |
|
| 66 | +/** |
| 67 | + * Shared handler for clicking a club in the right-rail SearchPanel. |
| 68 | + * Navigates to /orgs/{slug} so each Club tile is a real link to its org page. |
| 69 | + * Falls back to club id when no slug is supplied (current sample data uses id). |
| 70 | + */ |
| 71 | +function useClubClick() { |
| 72 | + const navigate = useNavigate(); |
| 73 | + return (club: Club) => { |
| 74 | + navigate(`/orgs/${club.id}`); |
| 75 | + }; |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Shared handler for clicking an organisation name in a post header. |
| 80 | + * Navigates to /orgs/{id} when the org has a slug. Without an id the click is a |
| 81 | + * no-op (the row stays focusable for the hover preview). |
| 82 | + */ |
| 83 | +function useOrgClick() { |
| 84 | + const navigate = useNavigate(); |
| 85 | + return (org: Organization) => { |
| 86 | + if (org.id) navigate(`/orgs/${org.id}`); |
| 87 | + }; |
| 88 | +} |
| 89 | + |
55 | 90 | function RoutedHome() { |
56 | 91 | const navigate = useNavigate(); |
| 92 | + const onClubClick = useClubClick(); |
| 93 | + const onOrgClick = useOrgClick(); |
57 | 94 | return ( |
58 | 95 | <Home |
59 | 96 | activeNavItem="home" |
60 | 97 | onNavigate={(id) => navigate(pathForNavItem(id))} |
61 | 98 | posts={SAMPLE_POSTS} |
62 | 99 | rsvpGroups={SAMPLE_RSVP_GROUPS} |
63 | 100 | clubs={SAMPLE_CLUBS} |
| 101 | + onClubClick={onClubClick} |
| 102 | + onOrgClick={onOrgClick} |
| 103 | + // Pressing Enter from /home pushes to /search?q=… so the search has a |
| 104 | + // shareable URL. Home still renders results inline either way. |
| 105 | + onSearchSubmit={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)} |
64 | 106 | /> |
65 | 107 | ); |
66 | 108 | } |
67 | 109 |
|
| 110 | +function RoutedSearch() { |
| 111 | + return <Search />; |
| 112 | +} |
| 113 | + |
68 | 114 | function RoutedBookmarks() { |
69 | 115 | const navigate = useNavigate(); |
| 116 | + const onClubClick = useClubClick(); |
| 117 | + const onOrgClick = useOrgClick(); |
70 | 118 | return ( |
71 | 119 | <Bookmarks |
72 | 120 | activeNavItem="bookmarks" |
73 | 121 | onNavigate={(id) => navigate(pathForNavItem(id))} |
| 122 | + posts={SAMPLE_BOOKMARKED_POSTS} |
| 123 | + rsvpGroups={SAMPLE_RSVP_GROUPS} |
| 124 | + clubs={SAMPLE_CLUBS} |
| 125 | + onClubClick={onClubClick} |
| 126 | + onOrgClick={onOrgClick} |
74 | 127 | /> |
75 | 128 | ); |
76 | 129 | } |
77 | 130 |
|
| 131 | +const SORT_OPTIONS = ["Alphabetical", "Most emails", "Recently added"] as const; |
| 132 | + |
78 | 133 | function RoutedSubscriptions() { |
79 | 134 | const navigate = useNavigate(); |
| 135 | + const onClubClick = useClubClick(); |
| 136 | + const [sortIndex, setSortIndex] = useState(0); |
80 | 137 | return ( |
81 | 138 | <Subscriptions |
82 | 139 | activeNavItem="subscriptions" |
83 | 140 | onNavigate={(id) => navigate(pathForNavItem(id))} |
| 141 | + subscriptionCount={SAMPLE_SUBSCRIPTIONS.length} |
| 142 | + subscriptions={SAMPLE_SUBSCRIPTIONS} |
| 143 | + sortLabel={SORT_OPTIONS[sortIndex]} |
| 144 | + onSortChange={() => setSortIndex((i) => (i + 1) % SORT_OPTIONS.length)} |
| 145 | + rsvpGroups={SAMPLE_RSVP_GROUPS} |
| 146 | + clubs={SAMPLE_CLUBS} |
| 147 | + onClubClick={onClubClick} |
84 | 148 | /> |
85 | 149 | ); |
86 | 150 | } |
87 | 151 |
|
| 152 | +/** |
| 153 | + * Renders the Org page for a given URL slug. |
| 154 | + * |
| 155 | + * Lookup logic: |
| 156 | + * • Read `slug` from useParams. |
| 157 | + * • If the slug is missing or absent from SAMPLE_ORGS, render an inline |
| 158 | + * "not found" state alongside the SideBar so navigation remains accessible. |
| 159 | + * (We chose inline-not-found over redirect so users see context for the |
| 160 | + * broken link instead of a silent bounce to /home.) |
| 161 | + * • Otherwise, hydrate the Org page with the matched profile. |
| 162 | + * |
| 163 | + * Local state: |
| 164 | + * • `isFollowing` is seeded from the profile and toggled by the Follow button. |
| 165 | + * • `feedSearchValue` powers the in-page posts feed search input. |
| 166 | + * • `sidePanelSearchValue` powers the right-rail search input. |
| 167 | + * • `tagFilter` / `timeFilter` cycle through a small set of options when the |
| 168 | + * filter chips are clicked, so the buttons feel interactive even though |
| 169 | + * filtering is not yet wired to data. |
| 170 | + */ |
| 171 | +const TAG_FILTER_OPTIONS = ["All tags", "Tech", "Outdoors", "For you"] as const; |
| 172 | +const TIME_FILTER_OPTIONS = [ |
| 173 | + "All time", |
| 174 | + "This week", |
| 175 | + "This month", |
| 176 | + "Past events", |
| 177 | +] as const; |
| 178 | + |
88 | 179 | function RoutedOrg() { |
89 | 180 | const navigate = useNavigate(); |
90 | | - return <Org onNavigate={(id) => navigate(pathForNavItem(id))} />; |
| 181 | + const onClubClick = useClubClick(); |
| 182 | + const onOrgClick = useOrgClick(); |
| 183 | + const { slug } = useParams<{ slug: string }>(); |
| 184 | + const profile = slug ? SAMPLE_ORGS[slug] : undefined; |
| 185 | + |
| 186 | + // Hooks must run unconditionally — declare them above any early return. |
| 187 | + const [isFollowing, setIsFollowing] = useState<boolean>( |
| 188 | + profile?.isFollowing ?? false, |
| 189 | + ); |
| 190 | + const [feedSearchValue, setFeedSearchValue] = useState(""); |
| 191 | + const [tagFilterIndex, setTagFilterIndex] = useState(0); |
| 192 | + const [timeFilterIndex, setTimeFilterIndex] = useState(0); |
| 193 | + |
| 194 | + if (!profile) { |
| 195 | + return ( |
| 196 | + <Org |
| 197 | + onNavigate={(id) => navigate(pathForNavItem(id))} |
| 198 | + orgName="Organization not found" |
| 199 | + orgDescription={ |
| 200 | + slug |
| 201 | + ? `We couldn't find an organization with the slug "${slug}". It may have been removed or renamed.` |
| 202 | + : "No organization slug was provided." |
| 203 | + } |
| 204 | + orgTags={[]} |
| 205 | + posts={[]} |
| 206 | + rsvpGroups={SAMPLE_RSVP_GROUPS} |
| 207 | + clubs={SAMPLE_CLUBS} |
| 208 | + onClubClick={onClubClick} |
| 209 | + onOrgClick={onOrgClick} |
| 210 | + /> |
| 211 | + ); |
| 212 | + } |
| 213 | + |
| 214 | + return ( |
| 215 | + <Org |
| 216 | + onNavigate={(id) => navigate(pathForNavItem(id))} |
| 217 | + orgName={profile.orgName} |
| 218 | + orgDescription={profile.orgDescription} |
| 219 | + orgAvatarUrl={profile.orgAvatarUrl} |
| 220 | + coverImageUrl={profile.coverImageUrl} |
| 221 | + isVerified={profile.isVerified} |
| 222 | + orgTags={profile.orgTags} |
| 223 | + loopSummary={profile.loopSummary} |
| 224 | + isFollowing={isFollowing} |
| 225 | + onFollow={() => setIsFollowing((prev) => !prev)} |
| 226 | + onWebsite={() => |
| 227 | + window.open(profile.websiteUrl, "_blank", "noopener,noreferrer") |
| 228 | + } |
| 229 | + onEmail={() => { |
| 230 | + window.location.href = `mailto:${profile.email}`; |
| 231 | + }} |
| 232 | + posts={profile.posts} |
| 233 | + feedSearchValue={feedSearchValue} |
| 234 | + onFeedSearchChange={setFeedSearchValue} |
| 235 | + onFeedSearchClear={() => setFeedSearchValue("")} |
| 236 | + tagFilter={TAG_FILTER_OPTIONS[tagFilterIndex]} |
| 237 | + onTagFilterChange={() => |
| 238 | + setTagFilterIndex((i) => (i + 1) % TAG_FILTER_OPTIONS.length) |
| 239 | + } |
| 240 | + timeFilter={TIME_FILTER_OPTIONS[timeFilterIndex]} |
| 241 | + onTimeFilterChange={() => |
| 242 | + setTimeFilterIndex((i) => (i + 1) % TIME_FILTER_OPTIONS.length) |
| 243 | + } |
| 244 | + rsvpGroups={SAMPLE_RSVP_GROUPS} |
| 245 | + clubs={SAMPLE_CLUBS} |
| 246 | + onClubClick={onClubClick} |
| 247 | + onOrgClick={onOrgClick} |
| 248 | + /> |
| 249 | + ); |
91 | 250 | } |
92 | 251 |
|
| 252 | +/** |
| 253 | + * RoutedProfile — owns the profile-modal state. |
| 254 | + * |
| 255 | + * Wraps the Profile dialog with a backdrop and centred layout. Manages selected |
| 256 | + * Major / Grad Year / Minor / Interests, plus a `saved` flag that swaps the |
| 257 | + * dialog body to the "Recalibrating your feed…" confirmation. After ~1500 ms in |
| 258 | + * the saved state the modal auto-dismisses by navigating back to /home. |
| 259 | + */ |
93 | 260 | function RoutedProfile() { |
94 | 261 | const navigate = useNavigate(); |
95 | | - return <Profile onClose={() => navigate("/home")} />; |
| 262 | + const [major, setMajor] = useState<string>("Computer Science"); |
| 263 | + const [gradYear, setGradYear] = useState<string>("2027"); |
| 264 | + const [minor, setMinor] = useState<string>("Linguistics"); |
| 265 | + const [interests, setInterests] = useState<string[]>([ |
| 266 | + "Tech", |
| 267 | + "Health", |
| 268 | + "Finance", |
| 269 | + "Education", |
| 270 | + ]); |
| 271 | + const [saved, setSaved] = useState(false); |
| 272 | + const dismissTimer = useRef<number | null>(null); |
| 273 | + |
| 274 | + useEffect(() => { |
| 275 | + return () => { |
| 276 | + if (dismissTimer.current !== null) { |
| 277 | + window.clearTimeout(dismissTimer.current); |
| 278 | + } |
| 279 | + }; |
| 280 | + }, []); |
| 281 | + |
| 282 | + const handleSave = () => { |
| 283 | + setSaved(true); |
| 284 | + dismissTimer.current = window.setTimeout(() => { |
| 285 | + navigate("/home"); |
| 286 | + }, 1500); |
| 287 | + }; |
| 288 | + |
| 289 | + const handleClose = () => { |
| 290 | + if (dismissTimer.current !== null) { |
| 291 | + window.clearTimeout(dismissTimer.current); |
| 292 | + dismissTimer.current = null; |
| 293 | + } |
| 294 | + navigate("/home"); |
| 295 | + }; |
| 296 | + |
| 297 | + return ( |
| 298 | + <> |
| 299 | + {/* |
| 300 | + * Render the Home dashboard underneath so the modal floats over it, |
| 301 | + * matching Figma node 633:4436. The modal's z-50 backdrop captures all |
| 302 | + * pointer events so the dashboard is visual-only while the modal is open. |
| 303 | + */} |
| 304 | + <RoutedHome /> |
| 305 | + <div |
| 306 | + className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" |
| 307 | + onClick={(e) => { |
| 308 | + // Click outside the card closes the modal. |
| 309 | + if (e.target === e.currentTarget) handleClose(); |
| 310 | + }} |
| 311 | + > |
| 312 | + <Profile |
| 313 | + userName="Megan" |
| 314 | + major={major} |
| 315 | + onMajorChange={setMajor} |
| 316 | + gradYear={gradYear} |
| 317 | + onGradYearChange={setGradYear} |
| 318 | + minor={minor} |
| 319 | + onMinorChange={setMinor} |
| 320 | + interests={interests} |
| 321 | + onInterestsChange={setInterests} |
| 322 | + saved={saved} |
| 323 | + onSave={handleSave} |
| 324 | + onClose={handleClose} |
| 325 | + /> |
| 326 | + </div> |
| 327 | + </> |
| 328 | + ); |
96 | 329 | } |
97 | 330 |
|
98 | 331 | function App() { |
@@ -142,6 +375,14 @@ function App() { |
142 | 375 | </ProtectedRoute> |
143 | 376 | } |
144 | 377 | /> |
| 378 | + <Route |
| 379 | + path="/search" |
| 380 | + element={ |
| 381 | + <ProtectedRoute> |
| 382 | + <RoutedSearch /> |
| 383 | + </ProtectedRoute> |
| 384 | + } |
| 385 | + /> |
145 | 386 |
|
146 | 387 | {/* Dev-only design system page */} |
147 | 388 | {import.meta.env.DEV && ( |
|
0 commit comments