open-navigator / web_app /src /pages /FeedSetup.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
19.4 kB
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 (
<div className="max-w-md mx-auto px-4 py-16 text-center" style={FONT}>
<div
className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl text-[32px]"
style={{ background: 'rgba(26,107,107,0.10)' }}
aria-hidden
>
🏠
</div>
<h1 className="mb-2 text-2xl font-bold" style={{ color: '#354F52' }}>
Sign in to personalize your feed
</h1>
<p className="mb-8 text-gray-600">
Tell us a little about your area and the issues you care about to unlock Close to Home.
</p>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => login('google')}
className="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-[15px] font-semibold text-gray-700 transition-colors hover:bg-gray-50"
>
Continue with Google
</button>
<button
type="button"
onClick={() => login('facebook')}
className="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-[15px] font-semibold text-gray-700 transition-colors hover:bg-gray-50"
>
Continue with Facebook
</button>
<button
type="button"
onClick={() => login('github')}
className="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-[15px] font-semibold text-gray-700 transition-colors hover:bg-gray-50"
>
Continue with GitHub
</button>
</div>
</div>
)
}
export default function FeedSetup() {
const navigate = useNavigate()
const { isAuthenticated, isLoading: authLoading, login, refreshUser } = useAuth()
const [locations, setLocations] = useState<LocationChip[]>([])
const [frames, setFrames] = useState<Set<string>>(() => new Set())
const [signals, setSignals] = useState<Set<string>>(() => new Set())
// Typeahead state
const [query, setQuery] = useState('')
const [results, setResults] = useState<PlaceHit[]>([])
const [searching, setSearching] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(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<string>, setter: (s: Set<string>) => 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 (
<Transition appear show as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={close} style={FONT}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 sm:items-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white text-left shadow-xl transition-all">
<button
type="button"
onClick={close}
aria-label="Close"
className="absolute right-4 top-4 z-10 text-gray-400 transition-colors hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
<div className="overflow-y-auto px-6 py-8 sm:px-8">
{authLoading ? (
<div className="px-4 py-16 text-center text-gray-500">Loading…</div>
) : !isAuthenticated ? (
<SignInPanel login={login} />
) : (
<>
<div className="mb-8 pr-8">
<Dialog.Title
as="h1"
className="text-3xl font-bold"
style={{ color: '#354F52' }}
>
Personalize your feed
</Dialog.Title>
<p className="mt-1 text-gray-600">
Set up Close to Home — civic activity near you, on the issues you care about.
</p>
</div>
{/* 1) Where do you live? */}
<section className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex items-center gap-2">
<MapPinIcon className="h-6 w-6 text-gray-600" />
<h2 className="text-xl font-semibold text-gray-900">Where do you live?</h2>
</div>
<p className="text-sm text-gray-500 mt-1">
Search for your city or town. The first place you add is your primary location.
</p>
</div>
<div className="px-6 py-4">
{locations.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{locations.map((l) => (
<span
key={l.name}
className="inline-flex items-center gap-2 rounded-full border border-[#cfe0db] bg-[#eef5f3] px-3 py-1.5 text-sm font-medium text-[#0f2b2b]"
>
<MapPinIcon className="h-4 w-4 text-[#1a6b6b]" aria-hidden />
{l.name}
{l.is_primary && (
<span className="rounded-full bg-[#1a6b6b] px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
primary
</span>
)}
<button
type="button"
onClick={() => removeLocation(l.name)}
aria-label={`Remove ${l.name}`}
className="text-[#56635e] hover:text-[#0f2b2b]"
>
<XMarkIcon className="h-4 w-4" aria-hidden />
</button>
</span>
))}
</div>
)}
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => 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 && (
<div className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
{searching ? (
<div className="px-4 py-3 text-sm text-gray-400">Searching…</div>
) : results.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-400">No matching places.</div>
) : (
results.map((hit, i) => {
const sub = [hit.county, hit.state || hit.state_code].filter(Boolean).join(', ')
return (
<button
key={`${hit.name}-${hit.latitude}-${hit.longitude}-${i}`}
type="button"
onClick={() => addLocation(hit)}
className="flex w-full items-start gap-2 px-4 py-2.5 text-left hover:bg-gray-50"
>
<MapPinIcon className="mt-0.5 h-4 w-4 shrink-0 text-[#1a6b6b]" aria-hidden />
<span>
<span className="block text-sm font-medium text-gray-900">{hit.name}</span>
{sub && <span className="block text-xs text-gray-500">{sub}</span>}
</span>
</button>
)
})
)}
</div>
)}
</div>
</div>
</section>
{/* 2) What do you care about? (value-frames) */}
<section className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-gray-200 px-6 py-4">
<h2 className="text-xl font-semibold text-gray-900">What do you care about?</h2>
<p className="text-sm text-gray-500 mt-1">Pick the value-frames that matter to you.</p>
</div>
<div className="px-6 py-4 flex flex-wrap gap-2">
{VALUE_FRAMES.map((f) => {
const on = frames.has(f.id)
return (
<button
key={f.id}
type="button"
onClick={() => toggle(frames, setFrames, f.id)}
aria-pressed={on}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm font-semibold transition-colors ${
on
? 'border-[#1a6b6b] bg-[#1a6b6b] text-white'
: 'border-gray-300 bg-white text-gray-700 hover:border-[#1a6b6b]'
}`}
>
<span aria-hidden>{f.em}</span>
{f.name}
</button>
)
})}
</div>
</section>
{/* 3) Signals to surface */}
<section className="bg-white rounded-lg shadow mb-6">
<div className="border-b border-gray-200 px-6 py-4">
<h2 className="text-xl font-semibold text-gray-900">Signals to surface</h2>
<p className="text-sm text-gray-500 mt-1">
Which editorial angles should lead your feed?
</p>
</div>
<div className="px-6 py-4 flex flex-wrap gap-2">
{SIGNALS.map((s) => {
const on = signals.has(s.id)
return (
<button
key={s.id}
type="button"
onClick={() => toggle(signals, setSignals, s.id)}
aria-pressed={on}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm font-semibold transition-colors ${
on
? 'border-[#1a6b6b] bg-[#1a6b6b] text-white'
: 'border-gray-300 bg-white text-gray-700 hover:border-[#1a6b6b]'
}`}
>
<span aria-hidden>{s.em}</span>
{s.label}
</button>
)
})}
</div>
</section>
{saveError && (
<div className="mb-4 flex items-center gap-2 rounded-lg bg-red-50 p-4 text-red-800">
<ExclamationCircleIcon className="h-5 w-5" />
<span>{saveError}</span>
</div>
)}
<div className="flex items-center gap-4">
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className="inline-flex items-center gap-2 rounded-lg px-6 py-2.5 text-[15px] font-semibold text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
style={{ backgroundColor: '#1a6b6b' }}
>
{saving ? (
'Saving…'
) : (
<>
<CheckCircleIcon className="h-5 w-5" />
Save & view my feed
</>
)}
</button>
{locations.length === 0 && (
<span className="text-sm text-gray-500">Add at least one location to save.</span>
)}
</div>
</>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}