From c1c041bbe91be06e6d2073f3c8ddbc174aaf93c2 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:04:18 -0800 Subject: [PATCH] feat: Add user vertical preferences API and onboarding UI 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 --- backend/main.py | 3 +- backend/routers/verticals.py | 239 ++++++++++++++++++ frontend/app/onboarding/page.tsx | 14 + .../components/onboarding/band-onboarding.tsx | 144 +++++++++++ 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 backend/routers/verticals.py create mode 100644 frontend/app/onboarding/page.tsx create mode 100644 frontend/components/onboarding/band-onboarding.tsx diff --git a/backend/main.py b/backend/main.py index d10c7fc..e5dff19 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/routers/verticals.py b/backend/routers/verticals.py new file mode 100644 index 0000000..551f823 --- /dev/null +++ b/backend/routers/verticals.py @@ -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} diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx new file mode 100644 index 0000000..84f8c81 --- /dev/null +++ b/frontend/app/onboarding/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/frontend/components/onboarding/band-onboarding.tsx b/frontend/components/onboarding/band-onboarding.tsx new file mode 100644 index 0000000..e10f1cf --- /dev/null +++ b/frontend/components/onboarding/band-onboarding.tsx @@ -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([]) + const [selected, setSelected] = useState([]) + 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 ( +
+
Loading bands...
+
+ ) + } + + return ( +
+
+

Pick Your Bands

+

+ Select the bands you follow. You can change this anytime. +

+
+ +
+ {verticals.map((vertical) => ( + toggleVertical(vertical.id)} + > + +
+ toggleVertical(vertical.id)} + /> + {vertical.name} +
+
+ {vertical.description && ( + + {vertical.description} + + )} +
+ ))} +
+ +
+ +
+
+ ) +}