feat: Add user vertical preferences API and onboarding UI
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
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:
parent
d8b949a965
commit
c1c041bbe9
4 changed files with 399 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
239
backend/routers/verticals.py
Normal file
239
backend/routers/verticals.py
Normal 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}
|
||||
14
frontend/app/onboarding/page.tsx
Normal file
14
frontend/app/onboarding/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
frontend/components/onboarding/band-onboarding.tsx
Normal file
144
frontend/components/onboarding/band-onboarding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue