Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ jobs:
- name: Test
run: npm test

# TODO: remove continue-on-error once the ~50 pre-existing lint errors are fixed.
- name: Lint
run: npm run lint
continue-on-error: true

# `next build` is intentionally not run here: it needs Supabase/Mapbox secrets.
10 changes: 10 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Event flyers/avatars are hot-linked from arbitrary third-party hosts
// (Luma, Google, Twitter CDNs, ...), so next/image's remotePatterns
// allow-list and optimizer don't fit. Plain <img> is intentional.
"@next/next/no-img-element": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Gitignored local one-off script (may exist in some checkouts).
"scripts/migrate-itinerary-ids.ts",
]),
]);

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface DateCount {
count: number;
}

export interface ItineraryEvent extends SheeetsEvent {}
export type ItineraryEvent = SheeetsEvent;

export interface Friend {
user_id: string;
Expand Down
1 change: 0 additions & 1 deletion scripts/ga4-dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ async function fetchAudiences(accessToken) {
// ---------- HTML generation ----------

function formatDate(yyyymmdd) {
const y = yyyymmdd.slice(0, 4);
const m = yyyymmdd.slice(4, 6);
const d = yyyymmdd.slice(6, 8);
return `${m}/${d}`;
Expand Down
43 changes: 26 additions & 17 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { Search, ArrowLeft } from 'lucide-react';
import { fetchEvents } from '@/lib/fetch-events';
import { FALLBACK_TABS } from '@/lib/constants';
Expand All @@ -24,6 +25,18 @@ import { useAdminConfigEditor } from '@/components/admin/hooks/useAdminConfigEdi

const SESSION_KEY = 'sheeets-admin-auth';

const noopSubscribe = () => () => {};
/** Admin password remembered for this tab (null during SSR/hydration). */
function useSessionPassword(): string | null {
const saved = useSyncExternalStore(
noopSubscribe,
() => sessionStorage.getItem(SESSION_KEY),
() => null
);
// Legacy sessions stored the literal 'true' instead of the password.
return saved && saved !== 'true' ? saved : null;
}

type AdminTab = 'submissions' | 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData' | 'errors';

const TAB_LABELS: { key: AdminTab; label: string }[] = [
Expand All @@ -43,11 +56,17 @@ const TAB_LABELS: { key: AdminTab; label: string }[] = [
];

export default function AdminPage() {
const [password, setPassword] = useState('');
const [typedPassword, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
const [authed, setAuthed] = useState(false);
const [loggedIn, setAuthed] = useState(false);
// Restore the session (sessionStorage) after hydration, without an effect.
const sessionPassword = useSessionPassword();
const authed = loggedIn || sessionPassword !== null;
const password = loggedIn || sessionPassword === null ? typedPassword : sessionPassword;
const [events, setEvents] = useState<ETHDenverEvent[]>([]);
const [loading, setLoading] = useState(false);
// Events are loading while authed and the fetch hasn't settled yet.
const [eventsSettled, setEventsSettled] = useState(false);
const loading = authed && !eventsSettled;
// Featured tab filters live here because their controls sit in the sticky header
const [conference, setConference] = useState(FALLBACK_TABS[0]?.name || '');
const [search, setSearch] = useState('');
Expand All @@ -67,22 +86,12 @@ export default function AdminPage() {
setConferences,
} = useAdminConfigEditor(authed, password);

// Check session on mount
useEffect(() => {
const saved = sessionStorage.getItem(SESSION_KEY);
if (saved && saved !== 'true') {
setAuthed(true);
setPassword(saved);
}
}, []);

// Fetch events when authed
useEffect(() => {
if (!authed) return;
setLoading(true);
fetchEvents()
.then(setEvents)
.finally(() => setLoading(false));
.finally(() => setEventsSettled(true));
}, [authed]);

// Merged conference tabs: FALLBACK_TABS + dynamic conferences from DB
Expand Down Expand Up @@ -170,9 +179,9 @@ export default function AdminPage() {
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<a href="/" className="text-stone-400 hover:text-white transition-colors">
<Link href="/" className="text-stone-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</a>
</Link>
<h1 className="text-lg font-bold">Admin</h1>
{activeTab === 'featured' && (
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full">
Expand Down
9 changes: 5 additions & 4 deletions src/app/ads/AdvertiseContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState, useRef, useMemo } from 'react';
import Link from 'next/link';
import { useAdminConfig } from '@/hooks/useAdminConfig';
import { useABTest } from '@/hooks/useABTest';
import { EVENT_TABS } from '@/lib/constants';
Expand Down Expand Up @@ -482,18 +483,18 @@ export function AdvertiseContent() {
</a>
</p>
<div className="flex gap-4">
<a
<Link
href="/api"
className="text-sm text-stone-400 hover:text-amber-400 transition-colors"
>
API Docs
</a>
<a
</Link>
<Link
href="/"
className="text-sm text-stone-400 hover:text-amber-400 transition-colors"
>
Back to App
</a>
</Link>
</div>
</div>
</div>
Expand Down
26 changes: 24 additions & 2 deletions src/app/api/ads/report/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
import { isAdminPassword } from '@/lib/admin-auth';

interface AdReportRpcRow {
ad_id: string;
ad_name: string | null;
placement: string;
impressions: number | string;
unique_impressions: number | string;
clicks: number | string;
unique_clicks: number | string;
ctr: number | string;
first_seen: string;
last_seen: string;
}

interface AdEventRow {
ad_id: string;
ad_name: string | null;
placement: string;
event_type: string;
visitor_id: string | null;
created_at: string;
}

function getSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
Expand Down Expand Up @@ -35,7 +57,7 @@ export async function GET(req: NextRequest) {
});

if (!rpcError && rpcData) {
const ads = (rpcData as any[]).map(a => ({
const ads = (rpcData as AdReportRpcRow[]).map(a => ({
ad_id: a.ad_id,
ad_name: a.ad_name || a.ad_id,
placement: a.placement,
Expand Down Expand Up @@ -63,7 +85,7 @@ export async function GET(req: NextRequest) {
}

// Fallback: client-side aggregation with pagination if RPC not available
let allEvents: any[] = [];
let allEvents: AdEventRow[] = [];
let offset = 0;
const pageSize = 1000;

Expand Down
12 changes: 4 additions & 8 deletions src/app/itinerary/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,10 @@ export default function ItineraryPage() {
() => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))],
[allItineraryEvents]
);
const [activeConference, setActiveConference] = useState('');

// Auto-select first conference when data loads
useMemo(() => {
if (conferences.length > 0 && !activeConference) {
setActiveConference(conferences[0]);
}
}, [conferences, activeConference]);
// Conference the user explicitly picked; until then, default to the first one
// that has itinerary events (derived during render, no state sync needed).
const [selectedConference, setActiveConference] = useState('');
const activeConference = selectedConference || conferences[0] || '';

const itineraryEvents = useMemo(
() => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference),
Expand Down
1 change: 1 addition & 0 deletions src/app/itinerary/s/[code]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export default function SharedItineraryPage() {
useEffect(() => {
if (pendingCopy && user && sharedEventIds) {
addMany(sharedEventIds);
// eslint-disable-next-line react-hooks/set-state-in-effect -- completes a copy deferred until external auth (sign-in) finishes
setCopyStatus('copied');
setPendingCopy(false);
setTimeout(() => setCopyStatus('idle'), 2500);
Expand Down
5 changes: 1 addition & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import Image from 'next/image';
import { getConferenceTabs, getUpcomingConferences } from '@/lib/get-conferences';
import { fetchEventsCached } from '@/lib/fetch-events-cached';
import { conferenceToTab } from '@/lib/conferences';
import type { TabConfig } from '@/lib/conferences';
import type { ConferenceConfig } from '@/lib/types';
import { NotifyForm } from '@/components/NotifyForm';

// Revalidate every 60s so new conferences appear without a redeploy
Expand Down Expand Up @@ -282,6 +279,7 @@ export default async function Home() {
{/* Spreadsheet link */}
<p className="text-sm mb-8" style={{ color: 'var(--theme-text-muted)' }}>
Looking for the old sheeets.xyz?{' '}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /data is a redirect (next.config.ts) to an external Google Sheet, so it needs a full-page navigation */}
<a href="/data" className="underline hover:opacity-80 transition-opacity" style={{ color: 'var(--theme-text-secondary)' }}>
Find it at plan.wtf/data
</a>
Expand All @@ -298,7 +296,6 @@ export default async function Home() {
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{upcoming.map((conf) => {
const tab = conferenceToTab(conf);
const city = getCity(conf.center, conf.timezone);
const dateRange = formatConfDateRange(conf.startDate, conf.endDate);
const daysLabel = getDaysAwayLabel(conf.startDate, conf.endDate);
Expand Down
11 changes: 2 additions & 9 deletions src/components/AddressAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { MapPin } from 'lucide-react';

interface AddressAutocompleteProps {
Expand All @@ -17,7 +17,6 @@ export function AddressAutocomplete({
placeholder = '1234 Market St, Denver',
}: AddressAutocompleteProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoaded, setIsLoaded] = useState(false);
const onChangeRef = useRef(onChange);
const onCoordsChangeRef = useRef(onCoordsChange);

Expand All @@ -31,10 +30,7 @@ export function AddressAutocomplete({

useEffect(() => {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
if (!apiKey) {
setIsLoaded(true);
return;
}
if (!apiKey) return;

const initAutocomplete = () => {
if (!inputRef.current || !window.google?.maps?.places) return;
Expand All @@ -61,8 +57,6 @@ export function AddressAutocomplete({
});
}
});

setIsLoaded(true);
};

// Already loaded
Expand Down Expand Up @@ -90,7 +84,6 @@ export function AddressAutocomplete({
script.async = true;
script.defer = true;
script.onload = () => initAutocomplete();
script.onerror = () => setIsLoaded(true);
document.head.appendChild(script);
}, []);

Expand Down
8 changes: 2 additions & 6 deletions src/components/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import { useFriends } from '@/hooks/useFriends';
import { useFriendRequests } from '@/hooks/useFriendRequests';
import type { ETHDenverEvent, NativeAd, UserSearchResult, FriendRequest } from '@/lib/types';
import { useAdminConfig } from '@/hooks/useAdminConfig';
import { trackAdClick, trackAdImpression } from '@/lib/analytics';
import { trackAdClick } from '@/lib/analytics';
import { trackAdEvent } from '@/lib/ad-tracking';
import { ExternalLink } from 'lucide-react';
import { isSafeHttpUrl } from '@/lib/utils';

interface AuthModalProps {
Expand Down Expand Up @@ -372,7 +371,7 @@ interface UserMenuProps {
activeConference?: string;
}

export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pendingIncomingCount: externalCount, externalRefreshFriends, activeConference }: UserMenuProps) {
export function UserMenu({ events, itinerary, onOpenFriends, pendingIncomingCount: externalCount, externalRefreshFriends, activeConference }: UserMenuProps) {
const { user, signOut } = useAuth();
const { profile, updateProfile, uploadAvatar } = useProfile();
const { friendCount, refreshFriends: localRefreshFriends } = useFriends();
Expand Down Expand Up @@ -405,7 +404,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend
const [company, setCompany] = useState('');
const [linkedinUrl, setLinkedinUrl] = useState('');
const [jobTitle, setJobTitle] = useState('');
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle');
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

Expand Down Expand Up @@ -527,7 +525,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend
if (saveTimeout.current) clearTimeout(saveTimeout.current);
setSaveStatus('idle');
saveTimeout.current = setTimeout(async () => {
setSaving(true);
await updateProfile({
display_name: displayName.trim() || null,
x_handle: xHandle.trim() || null,
Expand All @@ -537,7 +534,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend
linkedin_url: linkedinUrl.trim() || null,
job_title: jobTitle.trim() || null,
});
setSaving(false);
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2000);
}, 800);
Expand Down
13 changes: 7 additions & 6 deletions src/components/CheckInFAB.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ interface CheckInFABProps {
}

export function CheckInFAB({ liveItineraryCount, onCheckIn, loading, result }: CheckInFABProps) {
const [showResult, setShowResult] = useState(false);
// Each new result is shown for 3s; once its timer fires it is remembered as
// expired so the toast hides without a synchronous setState in the effect.
const [expiredResult, setExpiredResult] = useState<CheckInFABProps['result']>(null);
const showResult = result !== null && result !== expiredResult;

useEffect(() => {
if (result) {
setShowResult(true);
const timer = setTimeout(() => setShowResult(false), 3000);
return () => clearTimeout(timer);
}
if (!result) return;
const timer = setTimeout(() => setExpiredResult(result), 3000);
return () => clearTimeout(timer);
}, [result]);

if (liveItineraryCount <= 0) return null;
Expand Down
Loading
Loading