feat: Add user vertical preferences API and onboarding UI
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

Backend:
- Add routers/verticals.py with CRUD endpoints
- GET /verticals - list all bands
- POST /verticals/preferences/bulk - onboarding bulk set
- CRUD for individual preferences

Frontend:
- Add BandOnboarding component with checkbox grid
- Add /onboarding page route
- Calls bulk preferences API on submit
This commit is contained in:
fullsizemalt 2025-12-28 16:04:18 -08:00
parent d8b949a965
commit c1c041bbe9
4 changed files with 399 additions and 1 deletions

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI
import os
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals
from fastapi.middleware.cors import CORSMiddleware
@ -44,6 +44,7 @@ app.include_router(gamification.router)
app.include_router(videos.router)
app.include_router(musicians.router)
app.include_router(sequences.router)
app.include_router(verticals.router)
# Optional features - can be disabled via env vars

View file

@ -0,0 +1,239 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from database import get_session
from models import User, Vertical, UserVerticalPreference
from auth import get_current_user
from pydantic import BaseModel
from datetime import datetime
router = APIRouter(prefix="/verticals", tags=["verticals"])
class VerticalRead(BaseModel):
id: int
name: str
slug: str
description: str | None = None
class UserVerticalPreferenceRead(BaseModel):
vertical_id: int
vertical: VerticalRead
display_mode: str
priority: int
notify_on_show: bool
class UserVerticalPreferenceCreate(BaseModel):
vertical_id: int
display_mode: str = "primary" # primary, secondary, attribution_only, hidden
priority: int = 0
notify_on_show: bool = True
class UserVerticalPreferenceUpdate(BaseModel):
display_mode: str | None = None
priority: int | None = None
notify_on_show: bool | None = None
class BulkVerticalPreferencesCreate(BaseModel):
"""For onboarding - set multiple band preferences at once"""
vertical_ids: List[int]
display_mode: str = "primary"
# --- Public endpoints ---
@router.get("/", response_model=List[VerticalRead])
def list_verticals(session: Session = Depends(get_session)):
"""List all available verticals (bands)"""
verticals = session.exec(select(Vertical)).all()
return verticals
@router.get("/{slug}", response_model=VerticalRead)
def get_vertical(slug: str, session: Session = Depends(get_session)):
"""Get a specific vertical by slug"""
vertical = session.exec(select(Vertical).where(Vertical.slug == slug)).first()
if not vertical:
raise HTTPException(status_code=404, detail="Vertical not found")
return vertical
# --- User preference endpoints ---
@router.get("/preferences/me", response_model=List[UserVerticalPreferenceRead])
def get_my_vertical_preferences(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Get current user's band preferences"""
prefs = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.order_by(UserVerticalPreference.priority)
).all()
# Enrich with vertical data
result = []
for pref in prefs:
vertical = session.get(Vertical, pref.vertical_id)
if vertical:
result.append({
"vertical_id": pref.vertical_id,
"vertical": vertical,
"display_mode": pref.display_mode,
"priority": pref.priority,
"notify_on_show": pref.notify_on_show
})
return result
@router.post("/preferences", response_model=UserVerticalPreferenceRead)
def add_vertical_preference(
pref: UserVerticalPreferenceCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Add a band to user's preferences"""
# Check vertical exists
vertical = session.get(Vertical, pref.vertical_id)
if not vertical:
raise HTTPException(status_code=404, detail="Vertical not found")
# Check if already exists
existing = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.vertical_id == pref.vertical_id)
).first()
if existing:
raise HTTPException(status_code=400, detail="Preference already exists")
db_pref = UserVerticalPreference(
user_id=current_user.id,
vertical_id=pref.vertical_id,
display_mode=pref.display_mode,
priority=pref.priority,
notify_on_show=pref.notify_on_show
)
session.add(db_pref)
session.commit()
session.refresh(db_pref)
return {
"vertical_id": db_pref.vertical_id,
"vertical": vertical,
"display_mode": db_pref.display_mode,
"priority": db_pref.priority,
"notify_on_show": db_pref.notify_on_show
}
@router.post("/preferences/bulk", response_model=List[UserVerticalPreferenceRead])
def set_vertical_preferences_bulk(
data: BulkVerticalPreferencesCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Set multiple band preferences at once (for onboarding)"""
result = []
for idx, vid in enumerate(data.vertical_ids):
vertical = session.get(Vertical, vid)
if not vertical:
continue
# Upsert
existing = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.vertical_id == vid)
).first()
if existing:
existing.display_mode = data.display_mode
existing.priority = idx
session.add(existing)
pref = existing
else:
pref = UserVerticalPreference(
user_id=current_user.id,
vertical_id=vid,
display_mode=data.display_mode,
priority=idx,
notify_on_show=True
)
session.add(pref)
result.append({
"vertical_id": vid,
"vertical": vertical,
"display_mode": data.display_mode,
"priority": idx,
"notify_on_show": True
})
session.commit()
return result
@router.put("/preferences/{vertical_id}", response_model=UserVerticalPreferenceRead)
def update_vertical_preference(
vertical_id: int,
data: UserVerticalPreferenceUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Update a specific band preference"""
pref = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.vertical_id == vertical_id)
).first()
if not pref:
raise HTTPException(status_code=404, detail="Preference not found")
vertical = session.get(Vertical, vertical_id)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(pref, key, value)
session.add(pref)
session.commit()
session.refresh(pref)
return {
"vertical_id": pref.vertical_id,
"vertical": vertical,
"display_mode": pref.display_mode,
"priority": pref.priority,
"notify_on_show": pref.notify_on_show
}
@router.delete("/preferences/{vertical_id}")
def delete_vertical_preference(
vertical_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Remove a band from user's preferences"""
pref = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.vertical_id == vertical_id)
).first()
if not pref:
raise HTTPException(status_code=404, detail="Preference not found")
session.delete(pref)
session.commit()
return {"ok": True}

View file

@ -0,0 +1,14 @@
import { BandOnboarding } from "@/components/onboarding/band-onboarding"
export const metadata = {
title: "Pick Your Bands | Fediversion",
description: "Select the bands you want to follow on Fediversion"
}
export default function OnboardingPage() {
return (
<div className="py-8">
<BandOnboarding />
</div>
)
}

View file

@ -0,0 +1,144 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
interface Vertical {
id: number
name: string
slug: string
description: string | null
}
export function BandOnboarding({ onComplete }: { onComplete?: () => void }) {
const [verticals, setVerticals] = useState<Vertical[]>([])
const [selected, setSelected] = useState<number[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const { token } = useAuth()
const router = useRouter()
useEffect(() => {
async function fetchVerticals() {
try {
const res = await fetch(`${getApiUrl()}/verticals`)
if (res.ok) {
const data = await res.json()
setVerticals(data)
}
} catch (err) {
console.error("Failed to fetch verticals:", err)
} finally {
setLoading(false)
}
}
fetchVerticals()
}, [])
const toggleVertical = (id: number) => {
setSelected(prev =>
prev.includes(id)
? prev.filter(v => v !== id)
: [...prev, id]
)
}
const handleSubmit = async () => {
if (selected.length === 0) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/verticals/preferences/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
vertical_ids: selected,
display_mode: "primary"
})
})
if (res.ok) {
if (onComplete) {
onComplete()
} else {
// Navigate to first selected band
const firstVertical = verticals.find(v => v.id === selected[0])
if (firstVertical) {
router.push(`/${firstVertical.slug}`)
} else {
router.push("/")
}
}
}
} catch (err) {
console.error("Failed to save preferences:", err)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground">Loading bands...</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Pick Your Bands</h1>
<p className="text-muted-foreground">
Select the bands you follow. You can change this anytime.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{verticals.map((vertical) => (
<Card
key={vertical.id}
className={`cursor-pointer transition-all ${selected.includes(vertical.id)
? "ring-2 ring-primary"
: "hover:bg-accent"
}`}
onClick={() => toggleVertical(vertical.id)}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-3">
<Checkbox
checked={selected.includes(vertical.id)}
onCheckedChange={() => toggleVertical(vertical.id)}
/>
<CardTitle className="text-lg">{vertical.name}</CardTitle>
</div>
</CardHeader>
{vertical.description && (
<CardContent>
<CardDescription>{vertical.description}</CardDescription>
</CardContent>
)}
</Card>
))}
</div>
<div className="flex justify-center pt-4">
<Button
size="lg"
onClick={handleSubmit}
disabled={selected.length === 0 || saving}
>
{saving ? "Saving..." : `Continue with ${selected.length} band${selected.length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)
}