import { Fragment, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Dialog, Transition } from '@headlessui/react' import { MapPinIcon, CheckCircleIcon, ExclamationCircleIcon, XMarkIcon } from '@heroicons/react/24/outline' import api from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { toLensSlug, fromLensSlug, toSignalSlug, fromSignalSlug } from '../lib/feedSlugs' /** * FeedSetup — the /feed-setup wizard that captures a personalized "Close to * Home" profile: where you live, the value-frames you care about, and the * signals to surface. Doubles as an editor (pre-fills from GET /api/feed/config). * * Presented as a centered modal popup over a dimmed backdrop (not an inline * page in the content column). Dismissing it — close button, Esc, or backdrop * click — returns to '/'. Saving PUTs the full config (which marks * profile_completed=true server-side), then returns to '/' where Close-to-Home * is now unlocked. * * No fabricated data: location suggestions come ONLY from the real geocoder * (GET /api/feed/places); an empty/short query shows nothing. */ const FONT = { fontFamily: "'DM Sans', sans-serif" } as const // Value-frames — mirrors StoryLenses VALUE_FRAMES (id/name/emoji) so the wizard // reads the same as the homepage strip. Saved as backend lens slugs. const VALUE_FRAMES: { id: string; name: string; em: string }[] = [ { id: 'family', name: 'Family First', em: '\u{1F46A}' }, { id: 'faith', name: 'Faith & Community', em: '⛪' }, { id: 'charitable', name: 'Charitable Impact', em: '\u{1F91D}' }, { id: 'neighborhood', name: 'Neighborhood Life', em: '\u{1F3D8}\u{FE0F}' }, { id: 'education', name: 'Education', em: '\u{1F393}' }, { id: 'economy', name: 'Local Economy', em: '\u{1F4BC}' }, ] // Signals — mirrors StoryLenses LENSES (id/label/emoji). Saved as signal slugs. const SIGNALS: { id: string; label: string; em: string }[] = [ { id: 'contested', em: '\u{1F525}', label: 'Contested' }, { id: 'money', em: '\u{1F4B2}', label: 'Money Moves' }, { id: 'flags', em: '\u{1F928}', label: 'Raised Eyebrows' }, { id: 'soon', em: '⚡', label: 'Moving Fast' }, { id: 'next', em: '\u{1F4C5}', label: 'Watch Next' }, ] // ---- API shapes (mirrors api/routes/feed) ---- type SharedLevel = 'street' | 'district' | 'city' | 'county' | 'state' interface PlaceHit { name: string city?: string county?: string state?: string state_code?: string latitude: number longitude: number } interface LocationChip { name: string shared_level: SharedLevel is_primary: boolean state_code?: string state?: string county?: string place_fips?: string county_fips?: string latitude?: number longitude?: number jurisdiction_id?: string } interface FeedConfigOut { locations: LocationChip[] lenses: string[] signals: string[] profile_completed: boolean } function placeHitToChip(hit: PlaceHit, isPrimary: boolean): LocationChip { const display = hit.name || [hit.city, hit.state_code].filter(Boolean).join(', ') return { name: display, shared_level: 'city', is_primary: isPrimary, state_code: hit.state_code, state: hit.state, county: hit.county, latitude: hit.latitude, longitude: hit.longitude, } } function SignInPanel({ login }: { login: (provider: string) => void }) { return (
🏠

Sign in to personalize your feed

Tell us a little about your area and the issues you care about to unlock Close to Home.

) } export default function FeedSetup() { const navigate = useNavigate() const { isAuthenticated, isLoading: authLoading, login, refreshUser } = useAuth() const [locations, setLocations] = useState([]) const [frames, setFrames] = useState>(() => new Set()) const [signals, setSignals] = useState>(() => new Set()) // Typeahead state const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const prefilledRef = useRef(false) // Pre-fill from saved config once, so the wizard doubles as an editor. useEffect(() => { if (prefilledRef.current) return if (authLoading || !isAuthenticated) return prefilledRef.current = true let cancelled = false api .get('/feed/config') .then((r) => { if (cancelled) return const cfg = r.data as FeedConfigOut if (Array.isArray(cfg.locations) && cfg.locations.length > 0) { setLocations( cfg.locations.map((l) => ({ ...l, shared_level: (l.shared_level as SharedLevel) || 'city', })), ) } const frameIds = (cfg.lenses ?? []).map(fromLensSlug).filter((id): id is string => !!id) if (frameIds.length) setFrames(new Set(frameIds)) const signalIds = (cfg.signals ?? []).map(fromSignalSlug).filter((id): id is string => !!id) if (signalIds.length) setSignals(new Set(signalIds)) }) .catch(() => { // First-time setup or fetch failure — start from an empty form, never // fabricate selections. }) return () => { cancelled = true } }, [authLoading, isAuthenticated]) // Debounced geocoder typeahead (min 3 chars, real hits only). useEffect(() => { const q = query.trim() if (q.length < 3) { setResults([]) setSearching(false) return } setSearching(true) const handle = window.setTimeout(() => { let cancelled = false api .get('/feed/places', { params: { q } }) .then((r) => { if (cancelled) return setResults(((r.data as { results?: PlaceHit[] })?.results ?? []) as PlaceHit[]) }) .catch(() => { if (!cancelled) setResults([]) }) .finally(() => { if (!cancelled) setSearching(false) }) return () => { cancelled = true } }, 300) return () => window.clearTimeout(handle) }, [query]) const addLocation = (hit: PlaceHit) => { setLocations((prev) => { const isPrimary = prev.length === 0 const chip = placeHitToChip(hit, isPrimary) // De-dupe by display name. if (prev.some((l) => l.name === chip.name)) return prev return [...prev, chip] }) setQuery('') setResults([]) } const removeLocation = (name: string) => { setLocations((prev) => { const next = prev.filter((l) => l.name !== name) // Re-anchor primary onto the first remaining chip. if (next.length > 0 && !next.some((l) => l.is_primary)) { next[0] = { ...next[0], is_primary: true } } return next }) } const toggle = (set: Set, setter: (s: Set) => void, id: string) => { const next = new Set(set) if (next.has(id)) next.delete(id) else next.add(id) setter(next) } const canSave = locations.length > 0 && !saving const handleSave = async () => { if (!canSave) return setSaving(true) setSaveError(null) const body = { locations: locations.map((l) => ({ name: l.name, shared_level: l.shared_level || 'city', is_primary: l.is_primary, state_code: l.state_code, state: l.state, county: l.county, place_fips: l.place_fips, county_fips: l.county_fips, latitude: l.latitude, longitude: l.longitude, jurisdiction_id: l.jurisdiction_id, })), lenses: Array.from(frames).map(toLensSlug).filter((s): s is string => !!s), signals: Array.from(signals).map(toSignalSlug).filter((s): s is string => !!s), } try { await api.put('/feed/config', body) // Refresh auth (profile_completed + synced city/state) so Close-to-Home // unlocks immediately, then land them back on the homepage. await refreshUser() navigate('/') } catch { setSaveError('Could not save your feed. Please try again.') setSaving(false) } } // Dismiss the modal (close button, Esc, backdrop) — never trap the user on a // bare route; send them back to the homepage. Ignored mid-save. const close = () => { if (saving) return navigate('/') } return (
{authLoading ? (
Loading…
) : !isAuthenticated ? ( ) : ( <>
Personalize your feed

Set up Close to Home — civic activity near you, on the issues you care about.

{/* 1) Where do you live? */}

Where do you live?

Search for your city or town. The first place you add is your primary location.

{locations.length > 0 && (
{locations.map((l) => ( {l.name} {l.is_primary && ( primary )} ))}
)}
setQuery(e.target.value)} placeholder="e.g., Tuscaloosa, AL" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#1a6b6b]/40" /> {query.trim().length >= 3 && (
{searching ? (
Searching…
) : results.length === 0 ? (
No matching places.
) : ( results.map((hit, i) => { const sub = [hit.county, hit.state || hit.state_code].filter(Boolean).join(', ') return ( ) }) )}
)}
{/* 2) What do you care about? (value-frames) */}

What do you care about?

Pick the value-frames that matter to you.

{VALUE_FRAMES.map((f) => { const on = frames.has(f.id) return ( ) })}
{/* 3) Signals to surface */}

Signals to surface

Which editorial angles should lead your feed?

{SIGNALS.map((s) => { const on = signals.has(s.id) return ( ) })}
{saveError && (
{saveError}
)}
{locations.length === 0 && ( Add at least one location to save. )}
)}
) }