From 556b5ca0810b8a85127ddf6bb0d0c7066ec8409a Mon Sep 17 00:00:00 2001 From: prishaakapasi Date: Mon, 24 Aug 2026 17:17:52 -0600 Subject: [PATCH 1/5] MVP: core UI, Explore, event cards + map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up where #40 left off. Covers TIG-247, TIG-248, TIG-249. The big one is the event card. Explore, My Events, the map rail and org pages all had their own version, so the same event looked different depending on where you ran into it. Now there's one card with three densities (feed / compact / wide) and the actions only show up if you pass a handler, so the map doesn't need an RSVP button it can't use. While doing that I found getMyEvents was returning tags: [], friendsAttending: [], rsvpCount: 0 etc. as literals, so My Events could never have shown a tag or a friend no matter how it was styled. Fixed that with batched queries. RSVP/save/share/hide now all confirm with a toast, and RSVP gets a check + colour change — the label flip on its own was way too easy to miss. Hide used to just drop the event from state with no way back, so it collapses to a stub with an Unhide now. Turns out hide was never reachable anyway: the prop existed and Explore passed a handler, but nothing ever called it. "N attending" is clickable and lists people. Created events got Edit/Delete with a confirm. Map: clicking a pin now opens the real thing instead of that cramped popup (whose carousel arrows sat on top of the start time). One event opens the card, multiple open the sidebar. Timeline moved below the map so overlays stop landing on it, and added a ResizeObserver because Mapbox doesn't watch its own container and was leaving a blank strip after layout changes. Nav: rail collapses to icons and expands on hover, pushes content instead of covering it, and there's a proper bottom tab bar on phones since hover obviously doesn't work on touch. Responsive is only about half done — nav, overflow fixes, the event forms and Explore are sorted, the map isn't yet. Also fixed a nasty one in db.ts: it made a new Postgres pool on every hot reload and leaked the old one, so after a long dev session you'd hit the 100 connection cap and everything died with 53300, including auth. Cached on globalThis now. Same idea in getMapEvents, which was firing 2N+1 queries at once. Few other things while I was in there: nested -
+
{/* Timeline sidebar */} -
-
+
+
{TIMELINE_SECTIONS.map(({ id, label, color }) => ( {/* Attendees */} @@ -302,10 +294,12 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro )}
- - - {rsvpCount} attending - + + f.id))} + />
{event.friendsAttending.length > 0 && (

diff --git a/apps/web/src/app/(app)/events/create/create-event-form.tsx b/apps/web/src/app/(app)/events/create/create-event-form.tsx index 4f1ac3c..43e3321 100644 --- a/apps/web/src/app/(app)/events/create/create-event-form.tsx +++ b/apps/web/src/app/(app)/events/create/create-event-form.tsx @@ -239,10 +239,10 @@ export function CreateEventForm({ locations, userOrgs }: CreateEventFormProps) {

-
+
{/* Timeline sidebar */} -
-
+
+
{TIMELINE_SECTIONS.map(({ id, label, color }) => ( + + + + ); } diff --git a/apps/web/src/app/(app)/events/page.tsx b/apps/web/src/app/(app)/events/page.tsx index b393bd2..23c1a5f 100644 --- a/apps/web/src/app/(app)/events/page.tsx +++ b/apps/web/src/app/(app)/events/page.tsx @@ -11,8 +11,9 @@ export default async function MyEventsPage() { return ( + diff --git a/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx b/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx index 5ce4563..d07a80d 100644 --- a/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx +++ b/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx @@ -1,137 +1,128 @@ "use client"; -import { Bookmark, MapPin, Share2, X } from "lucide-react"; +import { X } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; -import { getEvent } from "~/actions/events"; +import { toast } from "sonner"; +import { type EventDetail, getEvent, toggleRsvp, toggleSave } from "~/actions/events"; +import { EventCard } from "~/components/events/event-card"; import { Button } from "~/components/ui/button"; -import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; -import { getEventColor } from "../_lib/map-helpers"; +import { Dialog, DialogClose, DialogContent, DialogTitle } from "~/components/ui/dialog"; interface EventDetailModalProps { eventId: string | null; onClose: () => void; } -interface EventDetail { - id: string; - title: string; - description: string | null; - datetime: Date; - endDatetime: Date | null; - locationName: string | null; - orgName: string | null; - flyerUrl: string | null; - tags: string[]; - rsvpCount: number; - isRsvped: boolean; - isSaved: boolean; -} - +/** + * The map's expanded event view. + * + * Renders the same `EventCard` the Explore feed uses, so an event looks + * identical whether you found it in the feed or on the map. This previously + * had a bespoke two-column layout, which meant the same event had two + * different visual treatments depending on where you opened it. + */ export function EventDetailModal({ eventId, onClose }: EventDetailModalProps) { const [event, setEvent] = useState(null); - const [isPending, startTransition] = useTransition(); + const [isLoading, startLoading] = useTransition(); + const [, startMutating] = useTransition(); useEffect(() => { if (!eventId) { setEvent(null); return; } - startTransition(async () => { - const result = await getEvent(eventId); - if (result) { - setEvent(result as unknown as EventDetail); - } + startLoading(async () => { + setEvent(await getEvent(eventId)); }); }, [eventId]); - return ( - !open && onClose()}> - - {event?.title ?? "Event Details"} - - {isPending || !event ? ( -
-
-
- Loading event -
-
- ) : ( -
- {/* Header with actions */} -
-
- {event.orgName && ( -

{event.orgName}

- )} -

{event.title}

-
-
- - - -
-
+ const handleRsvp = () => { + if (!event) return; + setEvent({ ...event, isRsvped: !event.isRsvped }); + startMutating(async () => { + const result = await toggleRsvp(event.id); + setEvent((prev) => + prev ? { ...prev, isRsvped: result.rsvped, rsvpCount: result.count } : prev, + ); + }); + }; - {/* Location + time */} -
- {event.locationName && ( - - - {event.locationName} - - )} - - - {new Date(event.datetime).toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })}{" "} - at{" "} - {new Date(event.datetime).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - -
+ const handleSave = () => { + if (!event) return; + setEvent({ ...event, isSaved: !event.isSaved }); + startMutating(async () => { + const result = await toggleSave(event.id); + setEvent((prev) => (prev ? { ...prev, isSaved: result.saved } : prev)); + }); + }; - {/* Tags */} - {event.tags.length > 0 && ( -
- {event.tags.map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} + const handleShare = async () => { + if (!event) return; + await navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); + toast.success("Link copied to clipboard"); + }; - {/* Description */} - {event.description && ( -

- {event.description} -

- )} + return ( + !open && onClose()}> + {/* + Transparent overlay and chrome: the card is the surface, floating over a + still-visible map so you keep track of which pin you opened. + */} + + {event?.title ?? "Event details"} - {/* RSVP button */} - -
+ +
+ + {isLoading || !event ? ( + + + + Loading event + + + ) : ( + )}
diff --git a/apps/web/src/app/(app)/map/_components/event-list-panel.tsx b/apps/web/src/app/(app)/map/_components/event-list-panel.tsx index 459dc15..04f0dd8 100644 --- a/apps/web/src/app/(app)/map/_components/event-list-panel.tsx +++ b/apps/web/src/app/(app)/map/_components/event-list-panel.tsx @@ -1,151 +1,98 @@ "use client"; -import { MapPin, Maximize2, X } from "lucide-react"; +import { MapPin, X } from "lucide-react"; import type { MapEvent } from "~/actions/map"; import { EmptyState } from "~/components/common/states"; +import { EventCard } from "~/components/events/event-card"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; -import { getEventColor } from "../_lib/map-helpers"; interface EventListPanelProps { + /** Only the events at the clicked pin, not the whole filtered set. */ events: MapEvent[]; - selectedLocation: string | null; + /** Name of the clicked location, shown in the header. */ + locationName: string; + /** + * The event whose detail card is currently open — the only card that gets + * the highlighted fill. + * + * This used to be the selected *location*, which meant clicking a pin holding + * four events highlighted all four cards at once. + */ + expandedEventId: string | null; onLocateEvent: (event: MapEvent) => void; onExpandEvent: (eventId: string) => void; onClose: () => void; } +/** + * The map's right-hand event list. + * + * Rendered as separate floating cards over the map rather than one opaque + * full-height panel, matching the design — the map stays visible in the gaps + * between cards. + */ export function EventListPanel({ events, - selectedLocation, + locationName, + expandedEventId, onLocateEvent, onExpandEvent, onClose, }: EventListPanelProps) { return ( -
- {/* Header */} -
- - {events.length} event{events.length !== 1 ? "s" : ""} - -
- {/* Event cards */} -
- {events.length === 0 && ( + {events.length === 0 ? ( +
- )} - - {events.map((event) => ( - + ) : ( + events.map((event, index) => ( + onLocateEvent(event)} - onExpand={() => onExpandEvent(event.id)} - /> - ))} -
-
- ); -} - -function PanelEventCard({ - event, - isActive, - onLocate, - onExpand, -}: { - event: MapEvent; - isActive: boolean; - onLocate: () => void; - onExpand: () => void; -}) { - const eventDate = new Date(event.rawDatetime); - - return ( - /* - * The card was a - - -
- - {/* Tags */} - {event.tags.length > 0 && ( -
- {event.tags.slice(0, 3).map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} - - {/* Org */} - {event.orgName && ( -

- {event.orgName} -

+ location={event.locationName} + tags={event.tags} + friendsAttending={event.friendsAttending} + density="compact" + source="map" + position={index} + onLocate={() => onLocateEvent(event)} + onOpen={() => onExpandEvent(event.id)} + className={cn( + "shrink-0 border-0 shadow-md transition-colors", + // Tinted only while this card's own detail view is open. + expandedEventId === event.id ? "bg-[#ECFCFC]" : "bg-white", + )} + /> + )) )}
); diff --git a/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx b/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx deleted file mode 100644 index 8a9cea8..0000000 --- a/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useState } from "react"; -import type { MapEvent } from "~/actions/map"; -import { MapPopup } from "./map-popup"; - -interface MapPopupCarouselProps { - events: MapEvent[]; - onExpand: (eventId: string) => void; -} - -export function MapPopupCarousel({ events, onExpand }: MapPopupCarouselProps) { - const [activeIndex, setActiveIndex] = useState(0); - const current = events[activeIndex]; - if (!current) return null; - - return ( -
- onExpand(current.id)} /> - - {events.length > 1 && ( - <> - {/* Left arrow */} - - - {/* Right arrow */} - - - {/* Pagination dots */} -
- {events.map((evt, i) => ( -
- - )} -
- ); -} diff --git a/apps/web/src/app/(app)/map/_components/map-popup.tsx b/apps/web/src/app/(app)/map/_components/map-popup.tsx deleted file mode 100644 index 56e3a65..0000000 --- a/apps/web/src/app/(app)/map/_components/map-popup.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Maximize2 } from "lucide-react"; -import type { MapEvent } from "~/actions/map"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; -import { URGENCY_STYLES, getEventColor, getRelativeLabel } from "../_lib/map-helpers"; - -interface MapPopupProps { - event: MapEvent; - onExpand: () => void; -} - -export function MapPopup({ event, onExpand }: MapPopupProps) { - const color = getEventColor(event.tags); - const rel = getRelativeLabel(event.rawDatetime); - const urgency = URGENCY_STYLES[rel.urgency]; - const eventDate = new Date(event.rawDatetime); - - return ( -
-
-
-

{event.title}

-

- {eventDate.toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - })} -

-

- {eventDate.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - {" - "} - {new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} -

-
- -
- - {event.tags.length > 0 && ( -
- {event.tags.slice(0, 3).map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} - - {event.orgName && ( -
- - {event.orgName} -
- )} -
- ); -} diff --git a/apps/web/src/app/(app)/map/_components/map-view.tsx b/apps/web/src/app/(app)/map/_components/map-view.tsx index 461a028..e3231e6 100644 --- a/apps/web/src/app/(app)/map/_components/map-view.tsx +++ b/apps/web/src/app/(app)/map/_components/map-view.tsx @@ -1,7 +1,8 @@ "use client"; -import { forwardRef, useCallback, useState } from "react"; -import { Map as MapGL, type MapRef, Marker, NavigationControl, Popup } from "react-map-gl/mapbox"; +import type { Map as MapboxMap } from "mapbox-gl"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; +import { Map as MapGL, type MapRef, Marker, NavigationControl } from "react-map-gl/mapbox"; import type { MapEvent } from "~/actions/map"; import { env } from "~/env"; import { @@ -13,115 +14,118 @@ import { } from "../_lib/map-constants"; import { getTimeGroup } from "../_lib/map-helpers"; import { MapPin } from "./map-pin"; -import { MapPopup } from "./map-popup"; -import { MapPopupCarousel } from "./map-popup-carousel"; +import { YouAreHere } from "./you-are-here"; interface MapViewProps { locationGroups: Map; selectedLocation: string | null; onSelectLocation: (locId: string | null) => void; onExpandEvent: (eventId: string) => void; + /** Open the sidebar listing every event at this location. */ + onShowLocationList: (locId: string) => void; } export const MapView = forwardRef(function MapView( - { locationGroups, selectedLocation, onSelectLocation, onExpandEvent }, + { locationGroups, selectedLocation, onSelectLocation, onExpandEvent, onShowLocationList }, ref, ) { - const [popupLoc, setPopupLoc] = useState<{ - lng: number; - lat: number; - events: MapEvent[]; - } | null>(null); - + /* + * Clicking a pin goes straight to the designed surface — no intermediate + * popup. One event opens the full detail card; several open the right-hand + * sidebar listing them. + * + * The previous mini-popup stacked a carousel's prev/next arrows on top of the + * popup body (`absolute top-1/2` over the content), so the arrows covered the + * event's own start time. + */ const handleMarkerClick = useCallback( (locId: string, locEvents: MapEvent[]) => { const first = locEvents[0]; if (!first) return; onSelectLocation(locId); - setPopupLoc({ lng: first.longitude, lat: first.latitude, events: locEvents }); + if (locEvents.length === 1) { + onExpandEvent(first.id); + } else { + onShowLocationList(locId); + } }, - [onSelectLocation], + [onSelectLocation, onExpandEvent, onShowLocationList], ); const handleMapClick = useCallback(() => { onSelectLocation(null); - setPopupLoc(null); }, [onSelectLocation]); - return ( - - + /* + * Mapbox sizes its canvas once and does not track its container, so any + * layout change after mount — the shell switching to a flex column, the nav + * rail animating, a window resize — leaves the canvas at its old size with + * blank space where the map should be. Re-measure whenever the box changes. + */ + const containerRef = useRef(null); + const mapInstance = useRef(null); - {Array.from(locationGroups.entries()).map(([locId, locEvents]) => { - const first = locEvents[0]; - if (!first) return null; - const isNow = getTimeGroup(first.rawDatetime) === "now"; - const isSelected = selectedLocation === locId; + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver(() => mapInstance.current?.resize()); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ { + mapInstance.current = e.target; + e.target.resize(); + }} + mapboxAccessToken={env.NEXT_PUBLIC_CAMPUS_MAP_TOKEN} + initialViewState={{ + longitude: PRINCETON_CENTER.lng, + latitude: PRINCETON_CENTER.lat, + zoom: DEFAULT_ZOOM, + pitch: 0, + bearing: 0, + }} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} + maxBounds={CAMPUS_BOUNDS} + mapStyle={env.NEXT_PUBLIC_CAMPUS_MAP_STYLE} + style={{ width: "100%", height: "100%" }} + reuseMaps + onClick={handleMapClick} + > + + - return ( - { - e.originalEvent.stopPropagation(); - handleMarkerClick(locId, locEvents); - }} - > - 1 ? locEvents.length : undefined} - isSelected={isSelected} - /> - - ); - })} + {Array.from(locationGroups.entries()).map(([locId, locEvents]) => { + const first = locEvents[0]; + if (!first) return null; + const isNow = getTimeGroup(first.rawDatetime) === "now"; + const isSelected = selectedLocation === locId; - {popupLoc && ( - { - setPopupLoc(null); - onSelectLocation(null); - }} - maxWidth="340px" - className="map-event-popup" - > - {popupLoc.events.length === 1 && popupLoc.events[0] ? ( - onExpandEvent(popupLoc.events[0]?.id ?? "")} - /> - ) : ( - onExpandEvent(eventId)} - /> - )} - - )} - + return ( + { + e.originalEvent.stopPropagation(); + handleMarkerClick(locId, locEvents); + }} + > + 1 ? locEvents.length : undefined} + isSelected={isSelected} + /> + + ); + })} + +
); }); diff --git a/apps/web/src/app/(app)/map/_components/you-are-here.tsx b/apps/web/src/app/(app)/map/_components/you-are-here.tsx new file mode 100644 index 0000000..494f4e2 --- /dev/null +++ b/apps/web/src/app/(app)/map/_components/you-are-here.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Marker } from "react-map-gl/mapbox"; +import { CAMPUS_BOUNDS } from "../_lib/map-constants"; + +/** + * The viewer's own position, labelled "You Are Here". + * + * Geolocation is requested once and failures are swallowed — the browser + * prompt can be denied, dismissed, or unavailable over plain HTTP, and none of + * those should surface an error on a map that works fine without it. The + * marker is simply omitted when there is no fix, or when the fix falls outside + * the campus bounds (so a user across the country doesn't yank the layout). + */ +export function YouAreHere() { + const [position, setPosition] = useState<{ lng: number; lat: number } | null>(null); + + useEffect(() => { + if (!navigator.geolocation) return; + + let cancelled = false; + navigator.geolocation.getCurrentPosition( + (pos) => { + if (cancelled) return; + const { longitude: lng, latitude: lat } = pos.coords; + const [[west, south], [east, north]] = CAMPUS_BOUNDS; + if (lng < west || lng > east || lat < south || lat > north) return; + setPosition({ lng, lat }); + }, + () => { + /* denied or unavailable — the map is still fully usable */ + }, + { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 }, + ); + + return () => { + cancelled = true; + }; + }, []); + + if (!position) return null; + + return ( + +
+ + + You Are Here + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/map/map-client.tsx b/apps/web/src/app/(app)/map/map-client.tsx index 10cbd4f..54544be 100644 --- a/apps/web/src/app/(app)/map/map-client.tsx +++ b/apps/web/src/app/(app)/map/map-client.tsx @@ -106,6 +106,15 @@ export function MapClient({ initialEvents }: MapClientProps) { return groups; }, [filteredEvents]); + /* + * The sidebar shows the events at the pin you clicked — not the whole + * filtered set. Clicking a pin with four events shows those four. + */ + const panelEvents = useMemo( + () => (selectedLocation ? (locationGroups.get(selectedLocation) ?? []) : []), + [selectedLocation, locationGroups], + ); + const eventCountByDate = useMemo(() => { const counts = new Map(); for (const event of events) { @@ -130,8 +139,14 @@ export function MapClient({ initialEvents }: MapClientProps) { setSelectedLocation(null); }, []); + /** A single event at a pin → open the full detail card. */ const handleExpandEvent = useCallback((eventId: string) => { setDetailEventId(eventId); + }, []); + + /** Several events at a pin → open the sidebar listing them. */ + const handleShowLocationList = useCallback((locId: string) => { + setSelectedLocation(locId); setPanelOpen(true); }, []); @@ -152,47 +167,69 @@ export function MapClient({ initialEvents }: MapClientProps) { map painted over the docked Sidebar and the route had to opt out of the standard chrome. It now shares the same nav as every other page. */} -
- {/* Map fills everything */} -
+
+ {/* Map area — everything that floats is scoped to this box, so no overlay + can land on the timeline below it */} +
-
- {/* ═══ Search bar + filter pills (top center) ═══ */} - {/* The wider right inset on ≥sm clears the TopBar's bell + avatar. */} -
-
- - + {/* ═══ Search bar + filter pills (top center) ═══ */} + {/* + The rail floats over the map on this route, so the left inset clears + its *expanded* 212px width — the controls are never swallowed when it + opens. The wider right inset on ≥sm clears the TopBar's bell + avatar. + */} +
+
+ + +
-
- {/* ═══ Right-side event list panel (floating overlay) ═══ */} -
-
+ {/* ═══ Right-side floating event cards ═══ */} + {/* No panel chrome — the cards themselves are the surface, so the map + shows through the gaps between them. */} +
setPanelOpen(false)} + onClose={() => { + setPanelOpen(false); + setSelectedLocation(null); + }} />
+ + {/* ═══ Loading overlay ═══ */} + {isPending && ( + + + + + Loading events + + + + )}
- {/* ═══ Timeline scrubber (bottom, full width) ═══ */} -
+ {/* ═══ Timeline scrubber — a bar beneath the map, not an overlay on it ═══ */} +
- - {/* ═══ Loading overlay ═══ */} - {isPending && ( -
-
-
- Loading events -
-
- )}
{/* ═══ Event detail modal ═══ */} diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx index cc8ccdc..efb66e6 100644 --- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx +++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx @@ -10,7 +10,7 @@ import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/ac import { Panel } from "~/components/common/panel"; import { SearchInput } from "~/components/common/search-input"; import { EmptyState } from "~/components/common/states"; -import { getCategoryColor } from "~/components/events/event-card"; +import { EventCard } from "~/components/events/event-card"; import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; @@ -157,39 +157,22 @@ export function OrgProfileClient({ org }: OrgProfileClientProps) {
Upcoming Events {org.upcomingEvents.length > 0 ? ( -
- {org.upcomingEvents.map((event) => { - const color = getCategoryColor(event.tags); - return ( - - -
- -
-
-

- {event.title} -

-

- {event.datetime} - · - {" "} - {event.locationName} -

-
- -
- ); - })} +
+ {org.upcomingEvents.map((event, index) => ( + + ))}
) : ( diff --git a/apps/web/src/app/(app)/settings/settings-client.tsx b/apps/web/src/app/(app)/settings/settings-client.tsx index 2f02c1d..e3bbff2 100644 --- a/apps/web/src/app/(app)/settings/settings-client.tsx +++ b/apps/web/src/app/(app)/settings/settings-client.tsx @@ -11,8 +11,14 @@ import { Field } from "~/components/common/field"; import { FilterChip } from "~/components/common/filter-chip"; import { Panel } from "~/components/common/panel"; import { SearchInput } from "~/components/common/search-input"; -import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; +import { + PageHeading, + PageShell, + SectionHeading, + TOP_BAR_CLEARANCE, +} from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; const INTEREST_TAGS = [ { id: "free food", label: "free food" }, @@ -150,8 +156,8 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { return ( - {/* Top bar — pr reserves space so buttons don't overlap the TopBar notification/avatar */} -
+ {/* Reserves space so these buttons don't collide with the floating TopBar. */} +
+ + + + Who's attending + + {count} {count === 1 ? "person has" : "people have"} RSVP'd to this event. + + + + {attendees.length === 0 ? ( + + ) : ( +
    + {attendees.map((person) => ( +
  • + {person.avatarUrl ? ( + + ) : ( + + {person.displayName[0]?.toUpperCase()} + + )} + + {person.displayName} + + {friendIds?.has(person.id) && ( + + Friend + + )} +
  • + ))} +
+ )} + + {/* + The attendee list can be shorter than the RSVP count — the server + returns a capped sample — so say so instead of silently under-reporting. + */} + {count > attendees.length && ( +

+ and {count - attendees.length} more +

+ )} +
+ + ); +} diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx index dde33eb..9829a0e 100644 --- a/apps/web/src/components/events/event-card.tsx +++ b/apps/web/src/components/events/event-card.tsx @@ -1,11 +1,28 @@ "use client"; -import { Bookmark, BookmarkCheck, Clock, MapPin, Maximize2, Share2 } from "lucide-react"; +import { + Bookmark, + BookmarkCheck, + Check, + Clock, + Edit3, + Eye, + EyeOff, + MapPin, + Maximize2, + Plus, + Share2, + Trash2, + Users, +} from "lucide-react"; import Link from "next/link"; import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { logInteraction } from "~/actions/interactions"; +import { AttendeesDialog } from "~/components/events/attendees-dialog"; import { AvatarStack } from "~/components/social/avatar-stack"; import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; export const CATEGORY_COLORS: Record = { "visual arts": { bg: "rgba(255,156,133,0.1)", accent: "#fb923c", text: "#9a3412" }, @@ -41,20 +58,55 @@ export interface EventCardProps { description?: string | null; tags: string[]; flyerUrl?: string | null; - rsvpCount: number; - friendsAttending: { id: string; displayName: string; avatarUrl?: string | null }[]; - isSaved: boolean; + rsvpCount?: number; + friendsAttending?: { id: string; displayName: string; avatarUrl?: string | null }[]; + /** Everyone attending — shown as an avatar stack + "N attending". */ + attendees?: { id: string; displayName: string; avatarUrl?: string | null }[]; + isSaved?: boolean; isRsvped?: boolean; + /** Actions render only when a handler is supplied. */ onSaveToggle?: () => void; onRsvpToggle?: () => void; onShare?: () => void; onHide?: () => void; + /** When true the card collapses to a stub that can be restored. */ + isHidden?: boolean; + onUnhide?: () => void; + /** Extra action, e.g. the map's "Show on map". */ + onLocate?: () => void; + /** + * Open the event in place instead of navigating to its page. The map uses + * this so opening a card doesn't throw you off the map. + */ + onOpen?: () => void; + /** + * `default` is the full feed card. `compact` drops the description and the + * friends sentence for narrow columns — the map's 320px rail and an org + * profile's event list. `wide` is the full-width row used by My Events. + */ + density?: "default" | "compact" | "wide"; + /** Google Calendar link; renders the Calendar action when supplied. */ + calendarUrl?: string; + /** + * Owner controls. Supplied only for events the viewer created, so the + * card itself does no permission checking. + */ + editHref?: string; + onDelete?: () => void; /** Where this card is displayed — logged with interactions */ source?: "feed" | "search" | "map" | "similar" | "notification"; /** Position in the list — for position bias correction */ position?: number; + className?: string; } +/** + * The event card, used on Explore, My Events, the map rail and org profiles. + * + * Each of those surfaces previously had its own card component, so the same + * event rendered with a different title size, tag colour and metadata order + * depending on where you saw it. Density is the only thing that varies now. + */ export function EventCard({ id, title, @@ -65,30 +117,33 @@ export function EventCard({ location, description, tags, - flyerUrl, rsvpCount, - friendsAttending, + friendsAttending = [], + attendees = [], isSaved, isRsvped, onSaveToggle, onRsvpToggle, onShare, onHide, + isHidden = false, + onUnhide, + onLocate, + onOpen, + density = "default", + calendarUrl, + editHref, + onDelete, source = "feed", position, + className, }: EventCardProps) { const cardRef = useRef(null); + const compact = density === "compact"; + const wide = density === "wide"; const displayedFriendNames = friendsAttending.slice(0, 2).map((friend) => friend.displayName); const remainingFriends = friendsAttending.length - displayedFriendNames.length; - const friendsText = - displayedFriendNames.length === 0 - ? "" - : displayedFriendNames.length === 1 - ? `${displayedFriendNames[0]} is also going to this event.` - : displayedFriendNames.length === 2 && remainingFriends === 0 - ? `${displayedFriendNames[0]} and ${displayedFriendNames[1]} are also going to this event.` - : `${displayedFriendNames.join(", ")} + ${remainingFriends} more are also going to this event.`; // Track view — IntersectionObserver fires after 1s of visibility useEffect(() => { @@ -115,185 +170,514 @@ export function EventCard({ logInteraction({ itemId: id, interactionType: "click", metadata: { source, position } }); }; - return ( -
- {/* Expand, Save & Share */} -
-
- - -
- {/* Expand button */} - + )}
-
- {/* Content */} -
- {/* Org */} + ); + } + + /* + * Wide layout: a full-width row for My Events, where each list is a single + * column and there's horizontal room to put the details and the blurb side + * by side, with the actions gathered in the header. + */ + if (wide) { + return ( +
+ {/* Header: org · calendar/RSVP · utilities */} +
{orgName && ( -
-
- {orgLogoUrl ? ( - {orgName} - ) : ( -
- )} +
+
+ {orgLogoUrl && }
-

- from - {orgId ? ( - e.stopPropagation()} - className="font-bold hover:text-forum-cerulean transition-colors duration-300ms" - > - {orgName} - - ) : ( - {orgName} - )} -

+ + {orgName} +
)} - {/* Title */} - -

- {title} -

- +
+ {calendarUrl && ( + + )} + {onRsvpToggle && ( + + )} - {/* Location & Time */} -
-
- - {location} +
+ {onSaveToggle && ( + + )} + {onShare && ( + + )} +
-
- - {datetime} +
+
+ + {/* Body: details left, social + blurb right */} +
+
+ +

+ {title} +

+ +
+ {location && ( + + + {location} + + )} + + + {datetime} +
+ {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag, i) => ( + + {tag} + + ))} +
+ )} +
+ +
+ {friendsAttending.length > 0 && ( +
+ +

+ + {displayedFriendNames.join(", ")} + + {remainingFriends > 0 && ( + + {remainingFriends} other + )}{" "} + added this event to their calendar! +

+
+ )} + {description && ( +

+ {description} +

+ )} + + See Details +
+ + {/* + Owner controls, pinned to the card's bottom-right. Only rendered for + events you created — the card does no permission checking of its own. + */} + {(editHref || onDelete) && ( +
+ {editHref && ( + + )} + {onDelete && ( + + )} +
+ )}
+ ); + } - {/* Bottom: Tags + RSVP */} -
- {/* Tags */} -
-
- {tags.slice(0, 3).map((tag) => ( - + {/* + Save, Share, Hide & Expand. + + The icon buttons are 32px boxes around a 16px glyph, so they carry 8px + of internal padding. The negative margins cancel that, putting the + glyphs on the same left/right edges as the text below. + */} + {hasUtilityRow && ( +
+
+ {onSaveToggle && ( + + )} + {onShare && ( + + )} + {/* Hide was previously an unreachable prop — no control ever called it. */} + {onHide && ( + + )}
+
+ )} - {/* Friends Attending */} -
- {friendsAttending.length > 0 && ( -
- -
+ {/* Org */} + {orgName && ( +
+
+ {orgLogoUrl && } +
+

+ {orgId ? ( + e.stopPropagation()} + className="font-bold transition-colors hover:text-forum-cerulean" + > + {orgName} + + ) : ( + {orgName} + )} +

+
+ )} + + {/* Title — opens in place when `onOpen` is given, otherwise navigates */} + {onOpen ? ( + + ) : ( + +

+ {title} +

+ + )} + + {/* Location & Time */} +
+ {location && ( +
+ + + {location} + +
+ )} +
+ + {datetime} +
+
+ + {/* + Tags. Compact cards stack them vertically and leave a right-hand gutter + so the corner avatars never sit on top of a label. + */} + {tags.length > 0 && ( +
0 && "pr-20") + : "flex-wrap", )} - {friendsText ? ( -

- {displayedFriendNames.length > 0 && ( - <> - - {displayedFriendNames.join( - displayedFriendNames.length === 2 && remainingFriends === 0 ? " and " : ", ", - )} - - {remainingFriends > 0 && ( - + {remainingFriends} more - )} - are also going to this event. - + > + {tags.slice(0, compact ? 2 : 3).map((tag, i) => ( + - ) : null} + > + {tag} + + ))}

+ )} - {/* Description */} -
- {description && ( -
-

- {description} -

+ {/* Friends attending — a corner cluster on compact cards, an inline row elsewhere */} + {friendsAttending.length > 0 && + (compact ? ( +
+ +
+ ) : ( +
+ +

+ + {displayedFriendNames.join( + displayedFriendNames.length === 2 && remainingFriends === 0 ? " and " : ", ", + )} + + {remainingFriends > 0 && + {remainingFriends} more} + {friendsAttending.length === 1 ? "is" : "are"} also going. +

+
+ ))} + + {/* Description — full card only */} + {!compact && description && ( +

+ {description} +

+ )} + + {/* Footer actions — right gutter keeps clear of the corner avatar cluster */} + {(onRsvpToggle || onLocate) && ( +
0 && "pr-20", + )} + > + {onLocate ? ( + + ) : ( + /* Avatar stack + "N attending", clickable to see the full list. */ +
+ {attendees.length > 0 && } + {rsvpCount ? ( +
+ + f.id))} + /> +
+ ) : null}
)} -
- {/* RSVP */} -
- + {onRsvpToggle && ( + + )}
-
+ )}
); } diff --git a/apps/web/src/components/layout/app-chrome.tsx b/apps/web/src/components/layout/app-chrome.tsx index 11f77a3..ea98118 100644 --- a/apps/web/src/components/layout/app-chrome.tsx +++ b/apps/web/src/components/layout/app-chrome.tsx @@ -2,14 +2,18 @@ import { usePathname } from "next/navigation"; import { GeometricBackground } from "~/components/layout/geometric-background"; +import { MobileNav } from "~/components/layout/mobile-nav"; import { Sidebar } from "~/components/layout/sidebar"; import { TopBar } from "~/components/layout/top-bar"; +import { cn } from "~/lib/utils"; /** * Routes whose content fills the shell edge-to-edge and manages its own * scrolling — the map canvas, which must not sit in a scroll container. * - * These still get the Sidebar and TopBar; only the `
` box changes. + * These still get the same Sidebar and TopBar as every other route; the rail + * just floats over the content rather than reserving a column beside it, so + * full-width furniture like the map's timeline can span the whole screen. */ const EDGE_TO_EDGE_ROUTES = new Set(["/map"]); @@ -29,7 +33,7 @@ export function AppChrome({ children }: { children: React.ReactNode }) {
- +
@@ -38,15 +42,19 @@ export function AppChrome({ children }: { children: React.ReactNode }) { Edge-to-edge pages get a positioning context with no scroll of their own; ordinary pages scroll vertically inside the shell. */} + {/* `pb-16` on phones reserves room for the fixed bottom tab bar. */}
{children}
+ +
); } diff --git a/apps/web/src/components/layout/mobile-nav.tsx b/apps/web/src/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..f39535a --- /dev/null +++ b/apps/web/src/components/layout/mobile-nav.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { LogOut } from "lucide-react"; +import { signOut } from "next-auth/react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { NAV_ITEMS, isNavItemActive } from "~/components/layout/nav-items"; +import { cn } from "~/lib/utils"; + +/** + * Bottom tab bar for phones. + * + * The docked rail expands on hover, which does not exist on touch — a + * touch-only user could never see the labels. Below `md` the rail is hidden + * entirely and this takes over: every destination visible at once, thumb-height, + * with labels always shown. + * + * `pb-[env(safe-area-inset-bottom)]` keeps the tabs clear of the iOS home + * indicator. + */ +export function MobileNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/notification-dropdown.tsx b/apps/web/src/components/layout/notification-dropdown.tsx index 9400962..ca76cff 100644 --- a/apps/web/src/components/layout/notification-dropdown.tsx +++ b/apps/web/src/components/layout/notification-dropdown.tsx @@ -90,7 +90,8 @@ export function NotificationDropdown() { {/* Header — italic serif title */}
diff --git a/apps/web/src/components/layout/page-shell.tsx b/apps/web/src/components/layout/page-shell.tsx index 771f81b..71dc261 100644 --- a/apps/web/src/components/layout/page-shell.tsx +++ b/apps/web/src/components/layout/page-shell.tsx @@ -9,22 +9,36 @@ import { cn } from "~/lib/utils"; * * Gutters step up with the viewport (20 → 32 → 40px) so the sidebar-adjacent * content never crowds the edge on tablet. + * + * Deliberately **left-aligned, not centred**. `max-w-*` differs per page, so + * centring with `mx-auto` produced a different left edge on every route: the + * wide Explore feed sat flush against the gutter while the narrower Events page + * was pushed inward by half the leftover width, and the two page titles did not + * line up. Left-aligning means the distance from the rail to the heading is the + * same on every page at every viewport, and `max-w-*` now only caps line length + * on the right. */ -const pageShellVariants = cva("mx-auto w-full px-5 py-6 sm:px-8 lg:px-10 lg:py-8", { +const pageShellVariants = cva("w-full px-5 py-6 sm:px-8 lg:px-10 lg:py-8", { variants: { width: { - /** Forms, settings, single-column reading. */ + /** Forms only — keeps inputs and prose at a readable measure. */ narrow: "max-w-3xl", - /** Default: list + detail pages. */ + /** Slightly tighter than the default; single-column reading. */ content: "max-w-5xl", - /** Multi-column dashboards (Explore). */ + /** Default: matches Explore, so list pages agree with the home screen. */ wide: "max-w-7xl", /** Opt out — the page manages its own width (e.g. full-bleed map). */ full: "max-w-none", }, }, + /* + * `wide` is the default so every list page spans the same width as the home + * screen. Previously the default was `content` (max-w-5xl), which left the + * Events and Friends tab bars 256px shorter than the Explore column and made + * them read as mis-centred against it. + */ defaultVariants: { - width: "content", + width: "wide", }, }); @@ -42,6 +56,17 @@ export function PageShell({ ); } +/** + * Right padding that keeps a page's top-right control clear of the TopBar. + * + * The TopBar (notification bell + avatar) is absolutely positioned over the + * content area on every route, so anything sharing that band — a page heading's + * trailing action — collides with it once the viewport is narrower than the + * shell's max width. Roughly 24px page padding + 36px bell + 12px gap + 40px + * avatar + 24px padding, rounded up. + */ +export const TOP_BAR_CLEARANCE = "pr-[140px]"; + /** * Page-level `

`. One ramp for every page — previously these ranged from * 48px to 60px with no rhyme, and none of them scaled down on mobile. @@ -51,15 +76,28 @@ export function PageHeading({ children, description, action, + clearTopBar = false, ...props }: React.ComponentProps<"h1"> & { /** Optional supporting line rendered under the title. */ description?: React.ReactNode; /** Optional trailing control (button, link) aligned to the title baseline. */ action?: React.ReactNode; + /** + * Reserve room for the floating TopBar. Set this when the heading is the + * first thing on the page *and* carries an `action`, so the two don't + * overlap. Off by default — headings rendered further down the page (an org + * profile's title inside its panel) sit below the TopBar already. + */ + clearTopBar?: boolean; }) { return ( -
+

+

- {location && ( + {hasLocation && ( {location} @@ -542,7 +549,7 @@ export function EventCard({ {/* Location & Time */}
- {location && ( + {hasLocation && (
diff --git a/apps/web/src/components/layout/notification-dropdown.tsx b/apps/web/src/components/layout/notification-dropdown.tsx index ca76cff..83d860d 100644 --- a/apps/web/src/components/layout/notification-dropdown.tsx +++ b/apps/web/src/components/layout/notification-dropdown.tsx @@ -10,6 +10,7 @@ import { markAllNotificationsRead, markNotificationRead, } from "~/actions/notifications"; +import { ErrorState } from "~/components/common/states"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; @@ -31,13 +32,21 @@ export function NotificationDropdown() { const [open, setOpen] = useState(false); const [limit, setLimit] = useState(20); + const [loadFailed, setLoadFailed] = useState(false); + const fetchNotifications = useCallback(async () => { try { const data = await getNotifications(); setItems(data.items); setUnreadCount(data.unreadCount); + setLoadFailed(false); } catch { - // silently fail + /* + * Recorded rather than swallowed. This polls on a 60s interval, so a + * toast per failure would be spam — the dropdown says so instead, and + * only when you open it. + */ + setLoadFailed(true); } }, []); @@ -114,6 +123,13 @@ export function NotificationDropdown() { }} /> )) + ) : loadFailed ? ( + /* An empty list and a failed fetch mean very different things. */ + ) : (
diff --git a/apps/web/src/components/layout/page-shell.tsx b/apps/web/src/components/layout/page-shell.tsx index 71dc261..a118013 100644 --- a/apps/web/src/components/layout/page-shell.tsx +++ b/apps/web/src/components/layout/page-shell.tsx @@ -57,15 +57,18 @@ export function PageShell({ } /** - * Right padding that keeps a page's top-right control clear of the TopBar. + * Keeps a page's top-right control clear of the floating TopBar. * * The TopBar (notification bell + avatar) is absolutely positioned over the * content area on every route, so anything sharing that band — a page heading's * trailing action — collides with it once the viewport is narrower than the - * shell's max width. Roughly 24px page padding + 36px bell + 12px gap + 40px - * avatar + 24px padding, rounded up. + * shell's max width. + * + * From `sm` up it reserves ~140px on the right: 24px page padding + 36px bell + + * 12px gap + 40px avatar + 24px padding, rounded up. On phones that would eat + * over a third of the screen, so the content drops *below* the bar instead. */ -export const TOP_BAR_CLEARANCE = "pr-[140px]"; +export const TOP_BAR_CLEARANCE = "pt-14 sm:pt-0 sm:pr-[140px]"; /** * Page-level `

`. One ramp for every page — previously these ranged from From a7559d8bfae28828ace031a4bc20b622b4b65f5d Mon Sep 17 00:00:00 2001 From: prishaakapasi Date: Tue, 25 Aug 2026 11:50:42 -0600 Subject: [PATCH 4/5] Two-column feed, card polish, and search race fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home feed now matches the Figma: event cards in a two-column grid with the highlights rail pinned to the right, so only the feed shifts when the nav rail expands. The shell runs full width instead of max-w-7xl. With a capped shell the whole row re-centred and the rail travelled with it. Card, on Explore and the map's expanded view alike: - utility icons (save, share, hide, open) go coral, hovering to a coral wash rather than the ghost variant's full-strength turquoise --accent - tags alternate yellow/turquoise, friends line reads "… added this event to their calendar!", description gains a See Details link - footer wraps as two units with the redundant Users glyph dropped, so "4 attending" stops breaking across two lines in a narrow column - the map modal picks up the shared date formatter and the "+ Calendar" action; buildGCalUrl was extracted for that call site and never wired up Fixes from review: - feed requests carry a monotonic id and a queued search is cancelled when filters change, so a debounce armed with the previous filters can no longer land last and overwrite the feed - save/RSVP flip optimistically and genuinely revert on failure; the catch used to invert a value that was still correct. Handlers rethrow so the card announces success only once the request resolves, instead of showing a success toast beside the error one - the result count uses the returned total, not the 20-row page length - getFeedEvents carries rawDatetime through; it was declared on FeedEvent for calendar links but dropped with the sort key Rail backdrop goes near-opaque over the map, where 50% let street names read straight through the nav labels. --- apps/web/src/actions/events.ts | 3 + .../src/app/(app)/explore/explore-client.tsx | 166 +++++++++---- .../map/_components/event-detail-modal.tsx | 26 +- apps/web/src/components/common/states.tsx | 5 +- apps/web/src/components/events/event-card.tsx | 231 ++++++++++++------ apps/web/src/components/layout/sidebar.tsx | 18 +- 6 files changed, 320 insertions(+), 129 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index ca169b2..974440b 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -440,6 +440,9 @@ export async function getFeedEvents(params?: { return { events: enriched.map(({ score: _score, _rawDatetime, ...event }) => ({ ...event, + // Carried through so the feed card can build its "+ Calendar" link; the + // sort key was being dropped here and the button never rendered. + rawDatetime: _rawDatetime.toISOString(), attendees: attendeesByEvent.get(event.id) ?? [], })), total, diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx index 0277081..0af7406 100644 --- a/apps/web/src/app/(app)/explore/explore-client.tsx +++ b/apps/web/src/app/(app)/explore/explore-client.tsx @@ -2,7 +2,7 @@ import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { useCallback, useMemo, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { type FeedEvent, @@ -17,6 +17,7 @@ import { EventCard } from "~/components/events/event-card"; import { EventFilters } from "~/components/events/event-filters"; import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; +import { buildGCalUrl } from "~/lib/calendar"; import { formatEventDateTime, formatRelativeDay } from "~/lib/date-format"; interface ExploreClientProps { @@ -71,7 +72,12 @@ export function ExploreClient({ }: ExploreClientProps) { const fallbackEvents = initialEvents.length > 0 ? initialEvents : [demoEvent]; const [events, setEvents] = useState(fallbackEvents); - const [_total, setTotal] = useState(initialEvents.length > 0 ? initialTotal : 1); + /* + * The full match count, not the page size. `getFeedEvents` pages at 20 while + * returning a separate count over every match, so reporting `events.length` + * capped the message at "20 events match" no matter how many there were. + */ + const [total, setTotal] = useState(initialEvents.length > 0 ? initialTotal : 1); const [activeFilters, setActiveFilters] = useState([]); /* * Hidden events stay in the list as collapsed stubs rather than being @@ -83,19 +89,36 @@ export function ExploreClient({ /** Set when a feed fetch fails, so the list can offer a retry. */ const [loadError, setLoadError] = useState(false); const searchTimeout = useRef>(null); + /* + * Monotonic id for feed requests. Only the most recently issued one may write + * to state: two fetches can be in flight at once (type, then toggle a filter), + * and without this the slower-but-older response lands last and wins. + */ + const latestRequest = useRef(0); const firstName = useMemo(() => userName.split(" ")[0] || "there", [userName]); + /** Drop a queued debounced search — it carries whatever filters were active when it was armed. */ + const cancelPendingSearch = useCallback(() => { + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + searchTimeout.current = null; + } + }, []); + const refreshEvents = useCallback((filters: string[], search: string) => { + const requestId = ++latestRequest.current; startTransition(async () => { try { const result = await getFeedEvents({ tags: filters.length > 0 ? filters : undefined, search: search || undefined, }); + if (requestId !== latestRequest.current) return; setEvents(result.events); setTotal(result.total); setLoadError(false); } catch { + if (requestId !== latestRequest.current) return; // Surfaced as an ErrorState with a retry rather than an empty feed, // which reads as "no events" and is a very different thing. setLoadError(true); @@ -103,15 +126,26 @@ export function ExploreClient({ }); }, []); + // A queued search outliving the component would fetch for a dead screen. + useEffect(() => cancelPendingSearch, [cancelPendingSearch]); + const handleFilterToggle = useCallback( (filterId: string) => { const next = activeFilters.includes(filterId) ? activeFilters.filter((f) => f !== filterId) : [...activeFilters, filterId]; setActiveFilters(next); - refreshEvents(next, searchQuery); + /* + * Cancel first. A search queued moments ago captured the *previous* + * filters, so letting it fire would re-fetch without the chip the user + * just clicked and overwrite this result — the feed and the active + * filters would disagree until the next interaction. The fetch below + * already carries the current query, so nothing is lost by dropping it. + */ + cancelPendingSearch(); + refreshEvents(next, searchQuery.trim()); }, - [activeFilters, searchQuery, refreshEvents], + [activeFilters, searchQuery, refreshEvents, cancelPendingSearch], ); /* @@ -129,20 +163,38 @@ export function ExploreClient({ [activeFilters, refreshEvents], ); - /** Roll the optimistic update back if the server rejects it. */ + /* + * Flip the card first so the bookmark reacts on click, then reconcile with + * whatever the server actually stored. Without the leading flip there was + * nothing to roll back, and the `catch` inverted a value that was still + * correct — leaving the UI disagreeing with the database. + * + * Rethrown so the card knows not to announce success; the error toast here + * is the only feedback the failure gets. + */ const handleSaveToggle = useCallback(async (eventId: string) => { + setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: !e.isSaved } : e))); try { const result = await toggleSave(eventId); setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: result.saved } : e)), ); - } catch { + } catch (error) { toast.error("Couldn't update saved events. Please try again."); setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: !e.isSaved } : e))); + throw error; } }, []); + /** Same optimistic-then-reconcile shape as `handleSaveToggle`, plus the count. */ const handleRsvpToggle = useCallback(async (eventId: string) => { + const flip = (e: FeedEvent) => ({ + ...e, + isRsvped: !e.isRsvped, + rsvpCount: Math.max(0, e.rsvpCount + (e.isRsvped ? -1 : 1)), + }); + + setEvents((prev) => prev.map((e) => (e.id === eventId ? flip(e) : e))); try { const result = await toggleRsvp(eventId); setEvents((prev) => @@ -150,11 +202,10 @@ export function ExploreClient({ e.id === eventId ? { ...e, isRsvped: result.rsvped, rsvpCount: result.count } : e, ), ); - } catch { + } catch (error) { toast.error("Couldn't update your RSVP. Please try again."); - setEvents((prev) => - prev.map((e) => (e.id === eventId ? { ...e, isRsvped: !e.isRsvped } : e)), - ); + setEvents((prev) => prev.map((e) => (e.id === eventId ? flip(e) : e))); + throw error; } }, []); @@ -168,11 +219,18 @@ export function ExploreClient({ * with its own internal scroll only kicks in at xl, where the right rail * appears. Nesting a scroll container inside the page scroller on a phone * made the feed feel stuck. + * + * The shell runs full width rather than `wide` (max-w-7xl) so the row's right + * edge is the content area's right edge. That is what keeps the highlights + * rail still while the nav rail expands: only the shell's *left* edge moves, + * so the greeting, search field and cards slide right and the feed narrows, + * while the rail — pinned to the right by `ml-auto` — does not budge. With a + * capped shell the whole row re-centred and the rail travelled with it. */ return ( - - {/* CENTER — Feed */} -
+ + {/* CENTER — Feed. Capped so cards stay card-sized on very wide displays. */} +
@@ -186,13 +244,13 @@ export function ExploreClient({ } > - Hi + Hello {firstName}, handleSearchChange(e.target.value)} /> @@ -204,7 +262,7 @@ export function ExploreClient({

{isPending ? "Searching…" - : `${events.length} ${events.length === 1 ? "event" : "events"} match`} + : `${total} ${total === 1 ? "event matches" : "events match"}`}

)} @@ -214,7 +272,10 @@ export function ExploreClient({ refreshEvents(activeFilters, searchQuery.trim())} + onRetry={() => { + cancelPendingSearch(); + refreshEvents(activeFilters, searchQuery.trim()); + }} /> ) : isPending && events.length === 0 ? ( @@ -228,31 +289,50 @@ export function ExploreClient({ } /> ) : ( - events.map((event, index) => ( - handleSaveToggle(event.id)} - onRsvpToggle={() => handleRsvpToggle(event.id)} - onShare={() => { - navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); - toast.success("Link copied to clipboard"); - }} - isHidden={hiddenIds.has(event.id)} - onHide={() => { - setHiddenIds((prev) => new Set(prev).add(event.id)); - }} - onUnhide={() => { - setHiddenIds((prev) => { - const next = new Set(prev); - next.delete(event.id); - return next; - }); - }} - /> - )) + /* + * Two columns from `sm` up. Cards stretch to the row height so a + * short description doesn't leave its neighbour's RSVP row floating + * at a different height. + */ +
+ {events.map((event, index) => ( + handleSaveToggle(event.id)} + onRsvpToggle={() => handleRsvpToggle(event.id)} + onShare={() => { + navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); + toast.success("Link copied to clipboard"); + }} + isHidden={hiddenIds.has(event.id)} + onHide={() => { + setHiddenIds((prev) => new Set(prev).add(event.id)); + }} + onUnhide={() => { + setHiddenIds((prev) => { + const next = new Set(prev); + next.delete(event.id); + return next; + }); + }} + /> + ))} +
)}
@@ -265,7 +345,7 @@ export function ExploreClient({ */}

-
@@ -581,7 +627,7 @@ export function EventCard({ key={tag} className={cn( "rounded-[10px] px-2 py-px font-dm-sans text-[12px] text-black", - compact && i === 1 ? "bg-forum-turquoise-50" : "bg-forum-yellow-50", + (compact ? i === 1 : i > 0) ? "bg-forum-turquoise-50" : "bg-forum-yellow-50", )} > {tag} @@ -598,31 +644,43 @@ export function EventCard({
) : (
- +

- {displayedFriendNames.join( - displayedFriendNames.length === 2 && remainingFriends === 0 ? " and " : ", ", - )} - - {remainingFriends > 0 && + {remainingFriends} more} - {friendsAttending.length === 1 ? "is" : "are"} also going. + {displayedFriendNames.join(", ")} + {remainingFriends > 0 && ` + ${remainingFriends} other`} + {" "} + added this event to their calendar!

))} - {/* Description — full card only */} + {/* Description — full card only. Clamped to the mock's three lines, with + "See Details" carrying the rest. */} {!compact && description && ( -

- {description} -

+ <> +

+ {description} +

+ + See Details + + )} {/* Footer actions — right gutter keeps clear of the corner avatar cluster */} - {(onRsvpToggle || onLocate) && ( + {(onRsvpToggle || onLocate || calendarUrl) && (
0 && "pr-20", )} > @@ -637,53 +695,78 @@ export function EventCard({ ) : ( /* Avatar stack + "N attending", clickable to see the full list. */ -
- {attendees.length > 0 && } - {rsvpCount ? ( -
- + (attendees.length > 0 || Boolean(rsvpCount)) && ( +
+ {attendees.length > 0 && } + {rsvpCount ? ( f.id))} + /* Sized to the card's own metadata scale — 14px bold black + shouted over the title's own details — and kept on one + line, which is what wrapped to "4 / attending". */ + className="whitespace-nowrap text-[12px] font-medium text-forum-dark-gray" /> -
- ) : null} -
+ ) : null} +
+ ) )} - {onRsvpToggle && ( - - )} + {/* Calendar + RSVP, gathered at the card's bottom-right as in the mock. */} +
+ {calendarUrl && ( + + )} + {onRsvpToggle && ( + + )} +
)}
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 3edad80..8bf9aad 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -84,8 +84,22 @@ export function Sidebar({ floating = false }: { floating?: boolean }) { "w-[64px] group-hover/rail:w-[200px] group-focus-within/rail:w-[200px]", )} > - {/* Rail backdrop — #ECFCFC at 50%. */} - + {/* + Rail backdrop — #ECFCFC. + + Half opacity reads fine over the app's near-white pages, but the map + is a dense, high-contrast canvas: at 50% the streets and building + labels ran straight through the nav labels. Over the map the panel + goes nearly solid and blurs what's behind it, so "Home" is read + against a flat tint rather than Nassau Street. + */} +