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 (
)
}