observer.disconnect();
- }, [event.id, event.name, event.isFeatured, conference]);
+ }, [event.id, event.name, event.isFeatured, conference, compact]);
// Track event impressions via IntersectionObserver (all events in list view)
useEffect(() => {
@@ -205,7 +201,7 @@ export const EventCard = memo(function EventCard({
observer.observe(el);
return () => observer.disconnect();
- }, [event.id, event.name, conference]);
+ }, [event.id, event.name, conference, compact]);
const handleCopyLink = (e: React.MouseEvent) => {
e.stopPropagation();
diff --git a/src/components/FriendMarker.tsx b/src/components/FriendMarker.tsx
index 35d86f41..786fb55c 100644
--- a/src/components/FriendMarker.tsx
+++ b/src/components/FriendMarker.tsx
@@ -5,6 +5,7 @@ import type { FriendLocation } from '@/lib/types';
import { timeAgo } from '@/lib/time-parse';
import { getDisplayName } from '@/lib/user-display';
import UserAvatar from './UserAvatar';
+import { useNow } from '@/hooks/useNow';
interface FriendMarkerProps {
location: FriendLocation;
@@ -16,7 +17,8 @@ export function FriendMarker({ location, zoom = 12 }: FriendMarkerProps) {
const showLabel = zoom >= 13;
// Stale if >1h old
- const ageMs = Date.now() - new Date(location.updated_at).getTime();
+ const now = useNow();
+ const ageMs = now - new Date(location.updated_at).getTime();
const isStale = ageMs > 60 * 60 * 1000;
const isRecent = ageMs < 5 * 60 * 1000;
diff --git a/src/components/GalleryView.tsx b/src/components/GalleryView.tsx
index f0e4fb14..b5095bb9 100644
--- a/src/components/GalleryView.tsx
+++ b/src/components/GalleryView.tsx
@@ -225,13 +225,9 @@ function GalleryCard({
export function GalleryView({
events,
- totalCount,
itinerary,
onItineraryToggle,
- friendsCountByEvent,
friendsByEvent,
- scrollContainerRef,
- conference,
liveEventIds,
}: GalleryViewProps) {
/* ---- date-group the events ---- */
@@ -332,13 +328,17 @@ export function GalleryView({
);
}
- // Track a running global index across date groups for lightbox
- let globalIndex = 0;
+ // Global (flattened) index of each date group's first event, for the lightbox
+ const groupStartIndices: number[] = [];
+ for (let i = 0, n = 0; i < dateGroups.length; i++) {
+ groupStartIndices.push(n);
+ n += dateGroups[i].events.length;
+ }
return (
{dateGroups.map((group, groupIdx) => {
- const startIndex = globalIndex;
+ const startIndex = groupStartIndices[groupIdx];
const cards = group.events.map((event, eventIdx) => {
const idx = startIndex + eventIdx;
return (
@@ -353,7 +353,6 @@ export function GalleryView({
/>
);
});
- globalIndex += group.events.length;
return (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index af2efbe5..d3f67b5f 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -2,6 +2,7 @@
import { memo, useState } from 'react';
import Image from 'next/image';
+import Link from 'next/link';
import { User, MapPin, Loader2 } from 'lucide-react';
import { trackAuthPrompt } from '@/lib/analytics';
import { ETHDenverEvent } from '@/lib/types';
@@ -40,7 +41,7 @@ export const Header = memo(function Header({
{/* Left: Branding */}
{/* Right: Controls */}
diff --git a/src/components/ItineraryPanel.tsx b/src/components/ItineraryPanel.tsx
index c434f2d5..df24d991 100644
--- a/src/components/ItineraryPanel.tsx
+++ b/src/components/ItineraryPanel.tsx
@@ -60,18 +60,17 @@ export function ItineraryPanel({
[allItineraryEvents]
);
- // Default to the active conference from the main view, fall back to first conference with events
- const [selectedConference, setSelectedConference] = useState('');
- // Sync selectedConference when the active conference or available conferences change.
- // Using useEffect instead of useMemo to avoid setState during render (which causes
- // cascading re-renders and was a contributing factor to the row duplication bug).
- useEffect(() => {
- if (activeConference && conferences.includes(activeConference)) {
- setSelectedConference(activeConference);
- } else if (conferences.length > 0 && !selectedConference) {
- setSelectedConference(conferences[0]);
- }
- }, [activeConference, conferences, selectedConference]);
+ // Default to the active conference from the main view, fall back to first conference with events.
+ // A tab the user picks wins until the main view's active conference changes.
+ // Derived during render (no effect) to avoid cascading re-renders.
+ const [picked, setPicked] = useState<{ conference: string; forActive: string | undefined } | null>(null);
+ const selectedConference =
+ picked && picked.forActive === activeConference && conferences.includes(picked.conference)
+ ? picked.conference
+ : activeConference && conferences.includes(activeConference)
+ ? activeConference
+ : conferences[0] ?? '';
+ const setSelectedConference = (conference: string) => setPicked({ conference, forActive: activeConference });
// Filter to selected conference
const itineraryEvents = useMemo(
diff --git a/src/components/ListView.tsx b/src/components/ListView.tsx
index 1c262794..8f8d162c 100644
--- a/src/components/ListView.tsx
+++ b/src/components/ListView.tsx
@@ -6,7 +6,6 @@ import type { ETHDenverEvent, ReactionEmoji, NativeAd, FriendInfo } from '@/lib/
import { formatDateLabel } from '@/lib/utils';
import { sortByStartTime } from '@/lib/time-parse';
import { EventCard } from './EventCard';
-import { FeaturedSection } from './FeaturedSection';
import NativeAdCard from './NativeAdCard';
import { imageCache, FlyerLightbox } from './OGImage';
@@ -122,7 +121,6 @@ function buildFlatList(
export const ListView = memo(function ListView({
events,
- totalCount,
itinerary,
onItineraryToggle,
friendsCountByEvent,
diff --git a/src/components/LockScreenTemplate.tsx b/src/components/LockScreenTemplate.tsx
index 29119639..ba18181a 100644
--- a/src/components/LockScreenTemplate.tsx
+++ b/src/components/LockScreenTemplate.tsx
@@ -15,13 +15,6 @@ interface LockScreenTemplateProps {
logoDataUrl?: string;
}
-const PLATFORM_LABELS: Record
= {
- x: 'X / Twitter',
- telegram: 'Telegram',
- linkedin: 'LinkedIn',
- friend: 'plan.wtf',
-};
-
const PLATFORM_LOGOS: Record = {
x: `data:image/svg+xml,${encodeURIComponent(' ')}`,
telegram: `data:image/svg+xml,${encodeURIComponent(' ')}`,
diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx
index f3435311..9f2a2d9b 100644
--- a/src/components/MapView.tsx
+++ b/src/components/MapView.tsx
@@ -60,7 +60,6 @@ function coordKey(lat: number, lng: number): string {
export function MapView({
events,
- onEventSelect,
itinerary,
onItineraryToggle,
isItineraryView = false,
@@ -93,7 +92,6 @@ export function MapView({
? 'mapbox://styles/mapbox/streets-v12'
: 'mapbox://styles/mapbox/dark-v11';
const mapRef = useRef(null);
- const hasFittedRef = useRef(false);
// Compute center from events with coordinates, excluding outliers
// Returns null when no located events (e.g. "Now" filters everything out)
diff --git a/src/components/OGImage.tsx b/src/components/OGImage.tsx
index 72e90311..6c4155b7 100644
--- a/src/components/OGImage.tsx
+++ b/src/components/OGImage.tsx
@@ -22,20 +22,18 @@ interface OGImageProps {
export const imageCache = new Map();
export function OGImage({ url, eventId, rsvpUrl, onOpenLightbox, isInItinerary, onItineraryToggle, friendsGoing }: OGImageProps) {
- const [imageUrl, setImageUrl] = useState(
- imageCache.get(url) ?? null
- );
- const [loaded, setLoaded] = useState(imageCache.has(url));
+ // Result of the last /api/og fetch, tagged with the url it was for so a url
+ // change falls back to the shared cache (or "loading") without an effect.
+ const [fetched, setFetched] = useState<{ url: string; imageUrl: string | null } | null>(null);
+ const fetchedForUrl = fetched?.url === url;
+ const imageUrl = fetchedForUrl ? fetched.imageUrl : imageCache.get(url) ?? null;
+ const loaded = fetchedForUrl || imageCache.has(url);
const [error, setError] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
- if (imageCache.has(url)) {
- setImageUrl(imageCache.get(url) ?? null);
- setLoaded(true);
- return;
- }
+ if (imageCache.has(url)) return;
const observer = new IntersectionObserver(
([entry]) => {
@@ -49,12 +47,11 @@ export function OGImage({ url, eventId, rsvpUrl, onOpenLightbox, isInItinerary,
.then((res) => res.json())
.then((data) => {
imageCache.set(url, data.imageUrl);
- setImageUrl(data.imageUrl);
- setLoaded(true);
+ setFetched({ url, imageUrl: data.imageUrl });
})
.catch(() => {
imageCache.set(url, null);
- setLoaded(true);
+ setFetched({ url, imageUrl: null });
});
},
{ rootMargin: '200px' }
diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx
index eba0e83f..8fd42f8c 100644
--- a/src/components/OnboardingWizard.tsx
+++ b/src/components/OnboardingWizard.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
+import { useIsClient } from '@/hooks/useIsClient';
import { createPortal } from 'react-dom';
import Image from 'next/image';
import { X, CirclePlus, Clock, Users, Map, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
@@ -76,11 +77,7 @@ export function OnboardingWizard({
const [currentStep, setCurrentStep] = useState(0);
const [selectedConference, setSelectedConference] = useState(conferenceTabs[0]?.name || availableConferences[0] || '');
const [selectedTags, setSelectedTags] = useState>(new Set());
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
+ const mounted = useIsClient();
useEffect(() => {
if (isOpen) {
diff --git a/src/components/POISearchBar.tsx b/src/components/POISearchBar.tsx
index a8033285..679a3ce0 100644
--- a/src/components/POISearchBar.tsx
+++ b/src/components/POISearchBar.tsx
@@ -70,15 +70,6 @@ export function POISearchBar({ onAddPOI, mapRef, onSignIn }: POISearchBarProps)
if (expanded) inputRef.current?.focus();
}, [expanded]);
- // Close on Escape
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === 'Escape') handleClose();
- };
- if (expanded) window.addEventListener('keydown', handler);
- return () => window.removeEventListener('keydown', handler);
- }, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps
-
const handleClose = useCallback(() => {
setExpanded(false);
setSelectedResult(null);
@@ -88,6 +79,16 @@ export function POISearchBar({ onAddPOI, mapRef, onSignIn }: POISearchBarProps)
clear();
}, [clear]);
+ // Close on Escape
+ useEffect(() => {
+ if (!expanded) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') handleClose();
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [expanded, handleClose]);
+
const handleSelectResult = useCallback(
async (result: GeocoderResult) => {
const resolved = await select(result.mapbox_id);
diff --git a/src/components/ProfileCardModal.tsx b/src/components/ProfileCardModal.tsx
index 67d1f267..c7092bbf 100644
--- a/src/components/ProfileCardModal.tsx
+++ b/src/components/ProfileCardModal.tsx
@@ -37,7 +37,17 @@ export default function ProfileCardModal({
telegramHandle,
}: ProfileCardModalProps) {
const { user } = useAuth();
- const [friendStatus, setFriendStatus] = useState('loading');
+ // Friend status fetched from Supabase, tagged with the viewer/profile pair it
+ // belongs to. 'not-logged-in' / 'self' / 'loading' are derived during render.
+ const fetchKey = isOpen && user && user.id !== userId ? `${user.id}:${userId}` : null;
+ const [fetchedStatus, setFetchedStatus] = useState<{ key: string; status: FriendStatus } | null>(null);
+ const friendStatus: FriendStatus = !user
+ ? 'not-logged-in'
+ : user.id === userId
+ ? 'self'
+ : fetchedStatus && fetchedStatus.key === fetchKey
+ ? fetchedStatus.status
+ : 'loading';
const [incomingRequestId, setIncomingRequestId] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
@@ -61,20 +71,11 @@ export default function ProfileCardModal({
// Fetch friend status
useEffect(() => {
- if (!isOpen) return;
-
- if (!user) {
- setFriendStatus('not-logged-in');
- return;
- }
-
- if (user.id === userId) {
- setFriendStatus('self');
- return;
- }
+ if (!fetchKey || !user) return;
+ const key = fetchKey;
+ const setFriendStatus = (status: FriendStatus) => setFetchedStatus({ key, status });
async function checkFriendStatus() {
- setFriendStatus('loading');
const currentUserId = user!.id;
// Check if already friends
@@ -129,7 +130,11 @@ export default function ProfileCardModal({
}
checkFriendStatus();
- }, [isOpen, user, userId]);
+ }, [fetchKey, user, userId]);
+
+ const setFriendStatus = (status: FriendStatus) => {
+ if (fetchKey) setFetchedStatus({ key: fetchKey, status });
+ };
const handleSendRequest = async () => {
setActionLoading(true);
diff --git a/src/components/RsvpOverlay.tsx b/src/components/RsvpOverlay.tsx
index e2ef3fc7..aaa92106 100644
--- a/src/components/RsvpOverlay.tsx
+++ b/src/components/RsvpOverlay.tsx
@@ -188,7 +188,7 @@ export function RsvpOverlay({
onClick={onConfirm}
className="flex-1 px-4 py-2.5 rounded-lg bg-green-600 hover:bg-green-500 text-white text-sm font-semibold transition-colors cursor-pointer"
>
- Done — I RSVP'd
+ Done — I RSVP'd
diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx
index 25f161a8..f737ab08 100644
--- a/src/components/ShareCardModal.tsx
+++ b/src/components/ShareCardModal.tsx
@@ -148,7 +148,7 @@ export function ShareCardModal({
setPreviewUrl(null);
setCopyStatus('idle');
}
- }, [isOpen, hiddenEventIds]);
+ }, [isOpen, hiddenEventIds, displayName, conferenceName]);
const handleCopy = useCallback(async () => {
if (!cardRef.current || selectedEvents.length === 0) return;
@@ -198,7 +198,7 @@ export function ShareCardModal({
} catch (err) {
console.error('Download failed:', err);
}
- }, [selectedEvents.length, conferenceName]);
+ }, [selectedEvents.length, cardTitle, conferenceName]);
if (!isOpen) return null;
diff --git a/src/components/SubmitEventModal.tsx b/src/components/SubmitEventModal.tsx
index 14ba081b..95c90325 100644
--- a/src/components/SubmitEventModal.tsx
+++ b/src/components/SubmitEventModal.tsx
@@ -35,7 +35,6 @@ const FORMAT_TAGS = TYPE_TAGS.filter((t) => !EXCLUDED_TAGS.includes(t));
const TOPIC_TAGS = Object.keys(VIBE_COLORS).filter(
(t) => !TYPE_TAGS.includes(t) && t !== 'default' && !EXCLUDED_TAGS.includes(t)
);
-const ALL_TAGS = [...FORMAT_TAGS, ...TOPIC_TAGS];
export function SubmitEventModal({ isOpen, onClose, upsellCopy, initialConference, conferenceTabs = [] }: SubmitEventModalProps) {
const [step, setStep] = useState
('input');
@@ -61,12 +60,17 @@ export function SubmitEventModal({ isOpen, onClose, upsellCopy, initialConferenc
const [hasBar, setHasBar] = useState(false);
const [addressCoords, setAddressCoords] = useState<{ lat: number; lng: number } | null>(null);
- // Sync conference when modal opens
+ // Sync conference when modal opens (adjust state during render on prop
+ // change instead of in an effect — https://react.dev/learn/you-might-not-need-an-effect)
+ const [prevOpenKey, setPrevOpenKey] = useState(null);
+ const openKey = isOpen ? `open:${initialConference ?? ''}` : null;
+ if (openKey !== prevOpenKey) {
+ setPrevOpenKey(openKey);
+ if (isOpen && initialConference) setConference(initialConference);
+ }
+
useEffect(() => {
- if (isOpen) {
- trackSubmitEventOpen();
- if (initialConference) setConference(initialConference);
- }
+ if (isOpen) trackSubmitEventOpen();
}, [isOpen, initialConference]);
function resetForm() {
diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx
index 3e6fd28d..c40baaf5 100644
--- a/src/components/TableView.tsx
+++ b/src/components/TableView.tsx
@@ -141,7 +141,6 @@ const COLUMN_COUNT = 7; // star, friends, time, organizer, event, location, tags
export const TableView = memo(function TableView({
events,
- totalCount,
itinerary,
onItineraryToggle,
onScrolledChange,
@@ -253,10 +252,13 @@ export const TableView = memo(function TableView({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedEvent]);
- // Reset to "Time" when groups change (e.g. filter change)
- useEffect(() => {
+ // Reset to "Time" when groups change (e.g. filter change). Adjusted during
+ // render rather than in an effect to avoid an extra commit.
+ const [prevGroups, setPrevGroups] = useState(groups);
+ if (groups !== prevGroups) {
+ setPrevGroups(groups);
setCurrentDateLabel('Time');
- }, [groups]);
+ }
// Track which date separator is at/near the top using IntersectionObserver
useEffect(() => {
@@ -954,13 +956,11 @@ function DateGroup({
itinerary,
onItineraryToggle,
setSeparatorRef,
- friendsCountByEvent,
friendsByEvent,
checkInCounts,
onSelectEvent,
conference,
featuredEvents,
- selectedEventId,
isSignedIn,
onSignIn,
liveEventIds,
diff --git a/src/components/admin/AdInventoryTab.tsx b/src/components/admin/AdInventoryTab.tsx
index 456f8683..c491327c 100644
--- a/src/components/admin/AdInventoryTab.tsx
+++ b/src/components/admin/AdInventoryTab.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useMemo } from 'react';
import { Loader2, Plus, Trash2, Pencil, X, Copy, MapPin, ChevronDown } from 'lucide-react';
import { FALLBACK_TABS } from '@/lib/constants';
import type { AdminConfig, AdInventoryItem, AdvertisePageConfig } from '@/lib/types';
@@ -55,9 +55,11 @@ export default function AdInventoryTab({ adminConfig, allConferenceTabs, configL
));
}, [adminConfig, adConference, adConferenceList]);
- // Load per-conference ad config when conference changes
- useEffect(() => {
- if (!adminConfig) return;
+ // Load per-conference ad config when conference / adminConfig change
+ // (adjusted during render instead of in an effect).
+ const [loadedFor, setLoadedFor] = useState<{ conference: string; config: AdminConfig } | null>(null);
+ if (adminConfig && (loadedFor?.conference !== adConference || loadedFor.config !== adminConfig)) {
+ setLoadedFor({ conference: adConference, config: adminConfig });
const inv = adminConfig[`ad_inventory:${adConference}`] as AdInventoryItem[] | undefined;
setAdInventory(inv && Array.isArray(inv) ? inv : []);
const page = adminConfig[`advertise_page:${adConference}`] as AdvertisePageConfig | undefined;
@@ -68,7 +70,7 @@ export default function AdInventoryTab({ adminConfig, allConferenceTabs, configL
});
setEditingInventoryId(null);
setShowCopyFrom(false);
- }, [adConference, adminConfig]);
+ }
function handleCopyFrom(sourceConf: string) {
if (!adminConfig) return;
diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx
index 116a10ee..9d69134a 100644
--- a/src/components/admin/ThemeTab.tsx
+++ b/src/components/admin/ThemeTab.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { FALLBACK_TABS } from '@/lib/constants';
import { THEME_OPTIONS, type ThemeId } from '@/lib/themes';
@@ -21,9 +21,11 @@ export default function ThemeTab({ adminConfig, allConferenceTabs, saving, saveM
const [themeConference, setThemeConference] = useState(FALLBACK_TABS[0]?.name || '');
const [selectedTheme, setSelectedTheme] = useState('dark');
- // Load per-conference theme when themeConference changes
- useEffect(() => {
- if (!adminConfig) return;
+ // Load per-conference theme when themeConference / adminConfig change
+ // (adjusted during render instead of in an effect).
+ const [loadedFor, setLoadedFor] = useState<{ conference: string; config: AdminConfig } | null>(null);
+ if (adminConfig && (loadedFor?.conference !== themeConference || loadedFor.config !== adminConfig)) {
+ setLoadedFor({ conference: themeConference, config: adminConfig });
const t = adminConfig[`theme:${themeConference}`] as string | undefined;
const validIds = THEME_OPTIONS.map(o => o.id) as string[];
if (t && validIds.includes(t)) {
@@ -31,7 +33,7 @@ export default function ThemeTab({ adminConfig, allConferenceTabs, saving, saveM
} else {
setSelectedTheme('dark');
}
- }, [themeConference, adminConfig]);
+ }
return (
diff --git a/src/components/admin/hooks/useAdminConfigEditor.ts b/src/components/admin/hooks/useAdminConfigEditor.ts
index 7d18910a..f992421b 100644
--- a/src/components/admin/hooks/useAdminConfigEditor.ts
+++ b/src/components/admin/hooks/useAdminConfigEditor.ts
@@ -22,7 +22,9 @@ export type SaveConfigFn = (key: string, value: unknown) => Promise
;
*/
export function useAdminConfigEditor(authed: boolean, password: string) {
const [adminConfig, setAdminConfig] = useState(null);
- const [configLoading, setConfigLoading] = useState(false);
+ // Loading while authed and the (single) config fetch hasn't settled yet.
+ const [configSettled, setConfigSettled] = useState(false);
+ const configLoading = authed && !configSettled;
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [conferences, setConferences] = useState([]);
@@ -30,7 +32,6 @@ export function useAdminConfigEditor(authed: boolean, password: string) {
// Fetch admin config when authed
useEffect(() => {
if (!authed) return;
- setConfigLoading(true);
fetch('/api/admin/config')
.then(res => res.json())
.then((data: AdminConfig) => {
@@ -52,7 +53,7 @@ export function useAdminConfigEditor(authed: boolean, password: string) {
}
})
.catch(() => {})
- .finally(() => setConfigLoading(false));
+ .finally(() => setConfigSettled(true));
}, [authed]);
async function saveConfig(key: string, value: unknown) {
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx
index 0369f034..5172b320 100644
--- a/src/contexts/ThemeContext.tsx
+++ b/src/contexts/ThemeContext.tsx
@@ -1,6 +1,6 @@
'use client';
-import { createContext, useContext, useEffect, useState, useCallback, useRef, type ReactNode } from 'react';
+import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { ThemeId, DEFAULT_THEME, THEME_OPTIONS } from '@/lib/themes';
const STORAGE_KEY = 'user-theme-override';
@@ -35,28 +35,15 @@ function readStoredTheme(): ThemeId | null {
}
export function ThemeProvider({ children, adminConfig, conference }: ThemeProviderProps) {
- const [theme, setThemeState] = useState(() => readStoredTheme() ?? DEFAULT_THEME);
- const userOverride = useRef(readStoredTheme() !== null);
+ // Theme the user picked manually (persisted); wins over the admin config.
+ const [userTheme, setUserTheme] = useState(readStoredTheme);
- // Read theme from admin config when conference changes,
- // but only if user hasn't manually toggled
- useEffect(() => {
- if (userOverride.current) return;
-
- if (!adminConfig || !conference) {
- setThemeState(DEFAULT_THEME);
- return;
- }
-
- const configKey = `theme:${conference}`;
- const configTheme = adminConfig[configKey] as string | undefined;
-
- if (configTheme && validIds.includes(configTheme)) {
- setThemeState(configTheme as ThemeId);
- } else {
- setThemeState(DEFAULT_THEME);
- }
- }, [adminConfig, conference]);
+ // Otherwise use the per-conference theme from admin config (derived during render).
+ const configTheme = adminConfig && conference
+ ? (adminConfig[`theme:${conference}`] as string | undefined)
+ : undefined;
+ const theme: ThemeId = userTheme
+ ?? (configTheme && validIds.includes(configTheme) ? (configTheme as ThemeId) : DEFAULT_THEME);
// Apply data-theme attribute to document
useEffect(() => {
@@ -67,8 +54,7 @@ export function ThemeProvider({ children, adminConfig, conference }: ThemeProvid
}, [theme]);
const setTheme = useCallback((newTheme: ThemeId) => {
- userOverride.current = true;
- setThemeState(newTheme);
+ setUserTheme(newTheme);
try {
localStorage.setItem(STORAGE_KEY, newTheme);
} catch {}
diff --git a/src/hooks/useABTest.ts b/src/hooks/useABTest.ts
index 6e1a857d..d49ec32e 100644
--- a/src/hooks/useABTest.ts
+++ b/src/hooks/useABTest.ts
@@ -1,6 +1,6 @@
'use client';
-import { useMemo, useEffect, useRef } from 'react';
+import { useMemo, useEffect, useRef, useCallback } from 'react';
import type { ABTest, ABTestVariant } from '@/lib/types';
import { getVisitorId, assignVariant, trackImpressionOnce, trackABEvent } from '@/lib/ab-testing';
@@ -55,19 +55,24 @@ export function useABTest({ test, trackImpression = true }: UseABTestOptions): U
const config = useMemo(() => variant?.config ?? {}, [variant]);
- const trackClick = useMemo(() => {
- if (!test || !variant) return () => {};
- return (metadata?: Record) => {
- trackABEvent(test.id, variant.id, 'click', metadata);
- };
- }, [test, variant]);
+ const testId = test?.id;
+ const variantId = variant?.id;
- const trackConversion = useMemo(() => {
- if (!test || !variant) return () => {};
- return (metadata?: Record) => {
- trackABEvent(test.id, variant.id, 'conversion', metadata);
- };
- }, [test, variant]);
+ const trackClick = useCallback(
+ (metadata?: Record) => {
+ if (!testId || !variantId) return;
+ trackABEvent(testId, variantId, 'click', metadata);
+ },
+ [testId, variantId]
+ );
+
+ const trackConversion = useCallback(
+ (metadata?: Record) => {
+ if (!testId || !variantId) return;
+ trackABEvent(testId, variantId, 'conversion', metadata);
+ },
+ [testId, variantId]
+ );
return { variant, config, trackClick, trackConversion, isActive };
}
diff --git a/src/hooks/useAuthGatedActions.ts b/src/hooks/useAuthGatedActions.ts
index e2a0b1b3..b695bf48 100644
--- a/src/hooks/useAuthGatedActions.ts
+++ b/src/hooks/useAuthGatedActions.ts
@@ -64,6 +64,7 @@ export function useAuthGatedActions({
if (user && itineraryReady && pendingStarRef.current) {
toggleItinerary(pendingStarRef.current);
pendingStarRef.current = null;
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- completes a deferred action once external auth + itinerary sync finish (pending action lives in a ref)
setShowAuthForStar(false);
}
}, [user, itineraryReady, toggleItinerary]);
diff --git a/src/hooks/useDragReorder.ts b/src/hooks/useDragReorder.ts
index 00532635..f9e64337 100644
--- a/src/hooks/useDragReorder.ts
+++ b/src/hooks/useDragReorder.ts
@@ -1,6 +1,6 @@
'use client';
-import { useState, useRef, useCallback } from 'react';
+import { useState, useRef, useCallback, useEffect } from 'react';
interface UseDragReorderOptions {
/** Called with the new ordered array when a drop completes */
@@ -50,7 +50,9 @@ export function useDragReorder({ onReorder }: UseDragReorderOptions) {
const itemRefs = useRef>(new Map());
const orderedIdsRef = useRef([]);
const onReorderRef = useRef(onReorder);
- onReorderRef.current = onReorder;
+ useEffect(() => {
+ onReorderRef.current = onReorder;
+ }, [onReorder]);
/** Keep the ordered IDs in sync so drag handlers can access them */
const setOrderedIds = useCallback((ids: string[]) => {
diff --git a/src/hooks/useEventComments.ts b/src/hooks/useEventComments.ts
index 916cd445..26c5cbb6 100644
--- a/src/hooks/useEventComments.ts
+++ b/src/hooks/useEventComments.ts
@@ -14,13 +14,17 @@ export function useEventComments(eventId: string | null) {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(false);
- useEffect(() => {
- if (!eventId) {
- setComments([]);
- return;
- }
+ // Reset when the event (or viewer) changes — adjusted during render rather
+ // than with a synchronous setState in the fetch effect.
+ const [prevKey, setPrevKey] = useState<{ eventId: string | null; user: typeof user } | null>(null);
+ if (prevKey?.eventId !== eventId || prevKey.user !== user) {
+ setPrevKey({ eventId, user });
+ if (!eventId) setComments([]);
+ else setLoading(true);
+ }
- setLoading(true);
+ useEffect(() => {
+ if (!eventId) return;
async function fetchComments() {
const { data, error } = await supabase
diff --git a/src/hooks/useFriendCode.ts b/src/hooks/useFriendCode.ts
index 3173c180..56b12aaa 100644
--- a/src/hooks/useFriendCode.ts
+++ b/src/hooks/useFriendCode.ts
@@ -121,6 +121,7 @@ export function useFriendCode({ openAuth, refreshFriends }: UseFriendCodeOptions
window.history.replaceState(null, '', newUrl);
if (user) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async: its setState calls all run after an await, not synchronously in the effect
redeemCode(code);
} else {
// Store pending code and trigger auth
diff --git a/src/hooks/useFriendRequests.ts b/src/hooks/useFriendRequests.ts
index ad5c9869..c4f1f4b5 100644
--- a/src/hooks/useFriendRequests.ts
+++ b/src/hooks/useFriendRequests.ts
@@ -25,6 +25,18 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions)
const [searchLoading, setSearchLoading] = useState(false);
const initialFetchDone = useRef(false);
+ // Clear request lists on sign-out (adjusted during render, not in an effect).
+ const signedOut = !authLoading && !user;
+ const [prevSignedOut, setPrevSignedOut] = useState(signedOut);
+ if (signedOut !== prevSignedOut) {
+ setPrevSignedOut(signedOut);
+ if (signedOut) {
+ setIncomingRequests([]);
+ setOutgoingRequests([]);
+ setPendingIncomingCount(0);
+ }
+ }
+
const fetchRequests = useCallback(async () => {
if (!user) return;
@@ -77,15 +89,13 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions)
if (authLoading) return;
if (!user) {
- setIncomingRequests([]);
- setOutgoingRequests([]);
- setPendingIncomingCount(0);
initialFetchDone.current = false;
return;
}
if (initialFetchDone.current) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async: its setState calls all run after an await, not synchronously in the effect
fetchRequests().then(() => {
initialFetchDone.current = true;
});
@@ -178,7 +188,7 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions)
// Then refresh from server in the background
fetchRequests();
refreshFriends();
- }, [user, fetchRequests, refreshFriends]);
+ }, [user, searchResults, fetchRequests, refreshFriends]);
const respondToRequest = useCallback(async (requestId: string, accept: boolean) => {
if (!user) return;
diff --git a/src/hooks/useIsClient.ts b/src/hooks/useIsClient.ts
new file mode 100644
index 00000000..5c229fdc
--- /dev/null
+++ b/src/hooks/useIsClient.ts
@@ -0,0 +1,14 @@
+'use client';
+
+import { useSyncExternalStore } from 'react';
+
+const noopSubscribe = () => () => {};
+
+/**
+ * `false` during SSR and hydration, `true` once rendering on the client.
+ * Hydration-safe replacement for the `useEffect(() => setMounted(true), [])` pattern
+ * (e.g. to gate `createPortal(…, document.body)`).
+ */
+export function useIsClient(): boolean {
+ return useSyncExternalStore(noopSubscribe, () => true, () => false);
+}
diff --git a/src/hooks/useItinerary.ts b/src/hooks/useItinerary.ts
index 13656372..3a4f4abd 100644
--- a/src/hooks/useItinerary.ts
+++ b/src/hooks/useItinerary.ts
@@ -57,6 +57,7 @@ export function useItinerary() {
if (authLoading) return;
if (user) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs from external auth state + localStorage, coupled to the Supabase sync state machine below (refs); refactor is high-risk
setReady(false); // Wait for Supabase sync before ready
try {
const saved = localStorage.getItem(STORAGE_KEYS.ITINERARY);
diff --git a/src/hooks/useLocalStorageState.ts b/src/hooks/useLocalStorageState.ts
new file mode 100644
index 00000000..c8e784b6
--- /dev/null
+++ b/src/hooks/useLocalStorageState.ts
@@ -0,0 +1,75 @@
+'use client';
+
+import { useCallback, useSyncExternalStore } from 'react';
+
+// Same-tab subscribers per key, notified when this hook writes the key.
+const listeners = new Map void>>();
+
+function notify(key: string) {
+ listeners.get(key)?.forEach((l) => l());
+}
+
+// Values written in this tab. Read first so state still updates when
+// localStorage is unavailable (private mode / quota errors).
+const written = new Map();
+
+function readRaw(key: string): string | null {
+ if (written.has(key)) return written.get(key)!;
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * localStorage-backed state that is safe for SSR + hydration.
+ *
+ * Renders `serverValue` on the server and during hydration, then switches to the
+ * stored value on the client (React re-renders right after hydration when they
+ * differ) — the useSyncExternalStore replacement for the
+ * `useEffect(() => setState(localStorage.getItem(...)), [])` pattern.
+ *
+ * `parse` maps the raw stored string (or null) to a value. It runs on every
+ * render, so it must return a stable (primitive or cached) value for the same input.
+ */
+export function useLocalStorageState(
+ key: string,
+ parse: (raw: string | null) => T,
+ serverValue: T,
+ serialize: (value: T) => string = String
+): [T, (value: T) => void] {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ let set = listeners.get(key);
+ if (!set) listeners.set(key, (set = new Set()));
+ set.add(onChange);
+ return () => {
+ set.delete(onChange);
+ };
+ },
+ [key]
+ );
+
+ const value = useSyncExternalStore(
+ subscribe,
+ () => parse(readRaw(key)),
+ () => serverValue
+ );
+
+ const setValue = useCallback(
+ (next: T) => {
+ const raw = serialize(next);
+ written.set(key, raw);
+ try {
+ localStorage.setItem(key, raw);
+ } catch {
+ // Storage unavailable (private mode / quota) — nothing to persist to.
+ }
+ notify(key);
+ },
+ [key, serialize]
+ );
+
+ return [value, setValue];
+}
diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts
new file mode 100644
index 00000000..23173a26
--- /dev/null
+++ b/src/hooks/useMediaQuery.ts
@@ -0,0 +1,24 @@
+'use client';
+
+import { useCallback, useSyncExternalStore } from 'react';
+
+/**
+ * Subscribes to a CSS media query. Returns `false` during SSR and hydration,
+ * then the live match state on the client (React re-renders after hydration
+ * if it differs), so it is hydration-safe.
+ */
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const mq = window.matchMedia(query);
+ mq.addEventListener('change', onChange);
+ return () => mq.removeEventListener('change', onChange);
+ },
+ [query]
+ );
+ return useSyncExternalStore(
+ subscribe,
+ () => window.matchMedia(query).matches,
+ () => false
+ );
+}
diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts
new file mode 100644
index 00000000..e7d2f553
--- /dev/null
+++ b/src/hooks/useNow.ts
@@ -0,0 +1,44 @@
+'use client';
+
+import { useSyncExternalStore } from 'react';
+
+const TICK_MS = 30_000;
+
+let now = 0;
+const listeners = new Set<() => void>();
+let timer: ReturnType | null = null;
+
+function tick() {
+ now = Date.now();
+ listeners.forEach((l) => l());
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ if (!timer) {
+ now = Date.now();
+ timer = setInterval(tick, TICK_MS);
+ }
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ };
+}
+
+function getSnapshot() {
+ // First read happens before any subscription: initialise lazily.
+ if (now === 0) now = Date.now();
+ return now;
+}
+
+/**
+ * Current time (ms), shared across components and refreshed every 30s.
+ * Keeps render pure (no Date.now() in the component body) while relative
+ * "x minutes ago" UI still updates over time. Returns 0 on the server.
+ */
+export function useNow(): number {
+ return useSyncExternalStore(subscribe, getSnapshot, () => 0);
+}
diff --git a/src/hooks/usePOIs.ts b/src/hooks/usePOIs.ts
index d6a26708..90f1a3c3 100644
--- a/src/hooks/usePOIs.ts
+++ b/src/hooks/usePOIs.ts
@@ -12,15 +12,22 @@ export function usePOIs() {
const [loading, setLoading] = useState(true);
const [ownerNames, setOwnerNames] = useState>(new Map());
- // Fetch on mount when authenticated
- useEffect(() => {
+ // Reset when the user changes (adjusted during render, not in an effect).
+ const [prevUser, setPrevUser] = useState(undefined);
+ if (user !== prevUser) {
+ setPrevUser(user);
if (!user) {
setPois([]);
setOwnerNames(new Map());
setLoading(false);
- return;
+ } else {
+ setLoading(true);
}
- setLoading(true);
+ }
+
+ // Fetch on mount when authenticated
+ useEffect(() => {
+ if (!user) return;
(async () => {
const { data } = await supabase
.from('pois')
diff --git a/src/hooks/useRsvp.ts b/src/hooks/useRsvp.ts
index 6a936f62..a820715f 100644
--- a/src/hooks/useRsvp.ts
+++ b/src/hooks/useRsvp.ts
@@ -9,9 +9,16 @@ export function useRsvp() {
const [confirmedIds, setConfirmedIds] = useState>(new Set());
const [activeRsvp, setActiveRsvp] = useState<{ eventId: string; lumaUrl: string; eventName: string } | null>(null);
+ // Clear on sign-out (adjusted during render, not in an effect).
+ const [prevUser, setPrevUser] = useState(user);
+ if (user !== prevUser) {
+ setPrevUser(user);
+ if (!user) setConfirmedIds(new Set());
+ }
+
// Load user's RSVPs on mount
useEffect(() => {
- if (!user) { setConfirmedIds(new Set()); return; }
+ if (!user) return;
supabase
.from('rsvps')
.select('event_id')
diff --git a/src/hooks/useViewMode.ts b/src/hooks/useViewMode.ts
index f07e26f9..fef5cf3a 100644
--- a/src/hooks/useViewMode.ts
+++ b/src/hooks/useViewMode.ts
@@ -1,43 +1,48 @@
'use client';
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useRef, useCallback } from 'react';
import type { ViewMode } from '@/lib/types';
import { STORAGE_KEYS } from '@/lib/storage-keys';
+import { useLocalStorageState } from './useLocalStorageState';
+
+// Width-based default for visitors with no saved view, decided once per page
+// load (on the first client read) so it doesn't flip when the window resizes.
+let defaultViewMode: ViewMode | null = null;
+
+function parseViewMode(saved: string | null): ViewMode {
+ if (saved === 'map' || saved === 'list' || saved === 'table' || saved === 'gallery') {
+ return saved;
+ }
+ if (!defaultViewMode) defaultViewMode = window.innerWidth >= 768 ? 'table' : 'list';
+ return defaultViewMode;
+}
/**
* Manages view mode (map/list/table) with localStorage persistence.
* Returns the current mode, setter, and scroll-tracking refs.
*/
export function useViewMode() {
- // Use 'list' as the SSR-safe default (most common on mobile)
- const [viewMode, setViewModeState] = useState('list');
+ // 'list' is the SSR/hydration value (most common on mobile); the saved or
+ // width-based mode is applied right after hydration (avoids a mismatch).
+ const [viewMode, persistViewMode] = useLocalStorageState(
+ STORAGE_KEYS.VIEW_MODE,
+ parseViewMode,
+ 'list'
+ );
const [contentScrolled, setContentScrolled] = useState(false);
- const restoredRef = useRef(false);
- // Restore from localStorage after mount (avoids hydration mismatch)
- useEffect(() => {
- const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE);
- if (saved === 'map' || saved === 'list' || saved === 'table' || saved === 'gallery') {
- setViewModeState(saved);
- } else {
- setViewModeState(window.innerWidth >= 768 ? 'table' : 'list');
- }
- restoredRef.current = true;
- }, []);
+ // List view scroll tracking (mirrors TableView's onScrolledChange)
+ const listMainRef = useRef(null);
+ const listLastScrollTopRef = useRef(0);
+ const listScrolledRef = useRef(false);
- // Persist to localStorage on change (after initial restore)
+ // Persist to localStorage on change
const setViewMode = useCallback((mode: ViewMode) => {
- setViewModeState(mode);
+ persistViewMode(mode);
setContentScrolled(false);
listScrolledRef.current = false;
listLastScrollTopRef.current = 0;
- localStorage.setItem(STORAGE_KEYS.VIEW_MODE, mode);
- }, []);
-
- // List view scroll tracking (mirrors TableView's onScrolledChange)
- const listMainRef = useRef(null);
- const listLastScrollTopRef = useRef(0);
- const listScrolledRef = useRef(false);
+ }, [persistViewMode]);
const handleListScroll = useCallback(() => {
const container = listMainRef.current;
diff --git a/src/lib/fetch-events.ts b/src/lib/fetch-events.ts
index 0069557b..6515cce6 100644
--- a/src/lib/fetch-events.ts
+++ b/src/lib/fetch-events.ts
@@ -89,7 +89,7 @@ export async function fetchEvents(runtimeAddresses?: GeoAddressMap, tabs?: TabCo
// Find header row, events start right after.
// If no header row found in data, check if Google Sheets already consumed it
// as column labels (happens when the sheet has no promo rows above the header).
- let headerIdx = findHeaderIndex(allRows);
+ const headerIdx = findHeaderIndex(allRows);
if (headerIdx === -1) {
// If Google Sheets already consumed the header as column labels
// (sheet has no promo rows above the header), all rows are data.
diff --git a/src/lib/sponsor-extraction.ts b/src/lib/sponsor-extraction.ts
index 95f9f709..3aaa3551 100644
--- a/src/lib/sponsor-extraction.ts
+++ b/src/lib/sponsor-extraction.ts
@@ -183,8 +183,8 @@ export async function fetchLumaApi(slug: string): Promise {
// Layer 1: Luma API extraction
// ---------------------------------------------------------------------------
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function extractSponsorsFromLuma(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped Luma API JSON, probed defensively below
apiData: any,
openaiClient?: OpenAI,
): Promise {
diff --git a/tests/debug-ids.spec.ts b/tests/debug-ids.spec.ts
index 68f269c5..31c424ab 100644
--- a/tests/debug-ids.spec.ts
+++ b/tests/debug-ids.spec.ts
@@ -18,7 +18,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => {
// Try to get the React key from the fiber
const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber'));
if (fiberKey) {
- const fiber = (row as any)[fiberKey];
+ const fiber = (row as unknown as Record)[fiberKey];
reactKeys.push(fiber?.key || 'no-key');
} else {
reactKeys.push('no-fiber');
@@ -43,7 +43,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => {
rows.forEach((row) => {
const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber'));
if (fiberKey) {
- const fiber = (row as any)[fiberKey];
+ const fiber = (row as unknown as Record)[fiberKey];
reactKeys.push(fiber?.key || 'no-key');
} else {
reactKeys.push('no-fiber');
@@ -66,7 +66,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => {
const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber'));
let key = 'no-fiber';
if (fiberKey) {
- const fiber = (row as any)[fiberKey];
+ const fiber = (row as unknown as Record)[fiberKey];
key = fiber?.key || 'no-key';
}
results.push({ index: i, key, text: row.textContent?.substring(0, 100) || '' });