Add discovery modes, personalization controls, taste profile page, updated pricing

- Discovery modes: Sonic Twin, Era Bridge, Deep Cuts, Rising Artists
- Discovery dial (Safe to Adventurous slider)
- Block genres/moods exclusion
- Thumbs down/dislike on recommendations
- My Taste page with Genre DNA breakdown, audio feature meters, listening personality
- Updated pricing: Free (5/week), Premium ($6.99/mo), Family ($12.99/mo coming soon)
- Weekly rate limiting instead of daily
- Alembic migration for new fields
This commit is contained in:
root
2026-03-31 00:21:58 -05:00
parent 789de25c1a
commit 1eea237c08
17 changed files with 898 additions and 113 deletions

View File

@@ -1,10 +1,26 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react'
import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard'
const DISCOVERY_MODES = [
{ id: 'discover', label: 'Discover', icon: Compass, description: 'General recommendations' },
{ id: 'sonic_twin', label: 'Sonic Twin', icon: Users, description: 'Underground artists who sound like your favorites' },
{ id: 'era_bridge', label: 'Era Bridge', icon: Clock, description: 'Classic artists who inspired your favorites' },
{ id: 'deep_cuts', label: 'Deep Cuts', icon: Disc3, description: 'B-sides and rarities from artists you know' },
{ id: 'rising', label: 'Rising', icon: TrendingUp, description: 'Under 50K listeners who fit your profile' },
] as const
const ADVENTUROUSNESS_LABELS: Record<number, string> = {
1: 'Safe',
2: 'Familiar',
3: 'Balanced',
4: 'Exploring',
5: 'Adventurous',
}
export default function Discover() {
const { user } = useAuth()
const [searchParams] = useSearchParams()
@@ -18,6 +34,10 @@ export default function Discover() {
const [error, setError] = useState('')
const [bandcampMode, setBandcampMode] = useState(false)
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
const [dislikingIds, setDislikingIds] = useState<Set<string>>(new Set())
const [mode, setMode] = useState('discover')
const [adventurousness, setAdventurousness] = useState(3)
const [excludeGenres, setExcludeGenres] = useState('')
useEffect(() => {
const load = async () => {
@@ -47,10 +67,13 @@ export default function Discover() {
const response = await generateRecommendations(
selectedPlaylist || undefined,
query.trim() || undefined,
bandcampMode
bandcampMode,
mode,
adventurousness,
excludeGenres.trim() || undefined,
)
setResults(response.recommendations)
setRemaining(response.remaining_today)
setRemaining(response.remaining_this_week)
} catch (err: any) {
setError(
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
@@ -78,6 +101,24 @@ export default function Discover() {
}
}
const handleDislike = async (id: string) => {
setDislikingIds((prev) => new Set(prev).add(id))
try {
const { disliked } = await dislikeRecommendation(id)
setResults((prev) =>
prev.map((r) => (r.id === id ? { ...r, disliked } : r))
)
} catch {
// silent
} finally {
setDislikingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -98,6 +139,25 @@ export default function Discover() {
</p>
</div>
{/* Discovery Modes */}
<div className="flex flex-wrap gap-2">
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
<button
key={id}
onClick={() => setMode(id)}
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-medium transition-all cursor-pointer border ${
mode === id
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
}`}
title={description}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* Discovery Form */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-5">
{/* Playlist Selector */}
@@ -135,6 +195,46 @@ export default function Discover() {
/>
</div>
{/* Discovery Dial */}
<div>
<label className="block text-sm font-medium text-charcoal mb-3">
Discovery dial
<span className="ml-2 text-charcoal-muted font-normal">
{ADVENTUROUSNESS_LABELS[adventurousness]}
</span>
</label>
<div className="relative">
<input
type="range"
min={1}
max={5}
step={1}
value={adventurousness}
onChange={(e) => setAdventurousness(Number(e.target.value))}
className="w-full h-2 bg-purple-100 rounded-full appearance-none cursor-pointer accent-purple"
/>
<div className="flex justify-between mt-1.5 px-0.5">
<span className="text-xs text-charcoal-muted">Safe</span>
<span className="text-xs text-charcoal-muted">Balanced</span>
<span className="text-xs text-charcoal-muted">Adventurous</span>
</div>
</div>
</div>
{/* Block Genres */}
<div>
<label className="block text-sm font-medium text-charcoal mb-2">
Exclude genres / moods
</label>
<input
type="text"
value={excludeGenres}
onChange={(e) => setExcludeGenres(e.target.value)}
placeholder="country, sad songs, metal"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
{/* Bandcamp Mode Toggle */}
<div className="flex items-center gap-3">
<button
@@ -160,7 +260,7 @@ export default function Discover() {
{!user?.is_pro && (
<p className="text-xs text-charcoal-muted flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-500" />
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 10} recommendations remaining today
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 5} discoveries remaining this week
</p>
)}
@@ -201,7 +301,9 @@ export default function Discover() {
key={rec.id}
recommendation={rec}
onToggleSave={handleToggleSave}
onDislike={handleDislike}
saving={savingIds.has(rec.id)}
disliking={dislikingIds.has(rec.id)}
/>
))}
</div>