Add Taste Match compatibility feature for comparing music taste between users

This commit is contained in:
root
2026-03-31 18:57:33 -05:00
parent 53ab59f0fc
commit 5b603f4acc
6 changed files with 475 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ import SharedView from './pages/SharedView'
import ArtistDive from './pages/ArtistDive'
import PlaylistGenerator from './pages/PlaylistGenerator'
import Timeline from './pages/Timeline'
import Compatibility from './pages/Compatibility'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -159,6 +160,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/compatibility"
element={
<ProtectedRoute>
<Layout>
<Compatibility />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/timeline"
element={

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
import { useAuth } from '../lib/auth'
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
@@ -14,6 +14,7 @@ const baseNavItems = [
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
{ path: '/compatibility', label: 'Taste Match', icon: Users },
{ path: '/saved', label: 'Saved', icon: Heart },
{ path: '/billing', label: 'Pro', icon: Crown },
]

View File

@@ -359,6 +359,21 @@ export const fixPlaylist = (playlistId: string) =>
export const getTasteProfile = () =>
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
// Taste Compatibility
export interface CompatibilityResponse {
friend_name: string
compatibility_score: number
shared_genres: string[]
unique_to_you: string[]
unique_to_them: string[]
shared_artists: string[]
insight: string
recommendations: { title: string; artist: string; reason: string }[]
}
export const checkCompatibility = (friendEmail: string) =>
api.post<CompatibilityResponse>('/profile/compatibility', { friend_email: friendEmail }).then((r) => r.data)
// Timeline
export interface TimelineDecade {
decade: string

View File

@@ -0,0 +1,228 @@
import { useState } from 'react'
import { Users, Loader2, Music, Sparkles, Heart } from 'lucide-react'
import { checkCompatibility, type CompatibilityResponse } from '../lib/api'
function ScoreCircle({ score }: { score: number }) {
const radius = 70
const circumference = 2 * Math.PI * radius
const offset = circumference - (score / 100) * circumference
const color =
score < 30 ? '#EF4444' : score < 60 ? '#EAB308' : '#22C55E'
const bgColor =
score < 30 ? 'text-red-100' : score < 60 ? 'text-yellow-100' : 'text-green-100'
const label =
score < 30 ? 'Different Wavelengths' : score < 60 ? 'Some Common Ground' : score < 80 ? 'Great Match' : 'Musical Soulmates'
return (
<div className="flex flex-col items-center gap-3">
<div className="relative w-44 h-44">
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
<circle
cx="80"
cy="80"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="10"
className={bgColor}
/>
<circle
cx="80"
cy="80"
r={radius}
fill="none"
stroke={color}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-bold text-charcoal">{score}%</span>
</div>
</div>
<span className="text-sm font-medium text-charcoal-muted">{label}</span>
</div>
)
}
function GenrePills({ genres, color }: { genres: string[]; color: string }) {
if (!genres.length) return <span className="text-sm text-charcoal-muted italic">None</span>
return (
<div className="flex flex-wrap gap-2">
{genres.map((g) => (
<span
key={g}
className={`px-3 py-1 rounded-full text-xs font-medium ${color}`}
>
{g}
</span>
))}
</div>
)
}
export default function Compatibility() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<CompatibilityResponse | null>(null)
const handleCompare = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setLoading(true)
setError(null)
setResult(null)
try {
const data = await checkCompatibility(email.trim())
setResult(data)
} catch (err: any) {
const msg = err.response?.data?.detail || 'Failed to check compatibility.'
setError(msg)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-8">
{/* Header */}
<div className="text-center">
<div className="w-14 h-14 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-7 h-7 text-purple" />
</div>
<h1 className="text-3xl font-bold text-charcoal">Taste Match</h1>
<p className="text-charcoal-muted mt-2">
See how your music taste compares with a friend
</p>
</div>
{/* Email input */}
<form onSubmit={handleCompare} className="max-w-md mx-auto flex gap-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your friend's email"
className="flex-1 px-4 py-3 rounded-xl border border-purple-200 bg-white text-charcoal placeholder-charcoal-muted/50 focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !email.trim()}
className="px-6 py-3 bg-purple text-white rounded-xl font-medium hover:bg-purple-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Compare'}
</button>
</form>
{/* Error */}
{error && (
<div className="max-w-md mx-auto bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700 text-center">
{error}
</div>
)}
{/* Loading */}
{loading && (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
<p className="text-charcoal-muted text-sm">Analyzing your musical chemistry...</p>
</div>
)}
{/* Results */}
{result && !loading && (
<div className="space-y-8 max-w-2xl mx-auto">
{/* Score */}
<div className="bg-white rounded-2xl border border-purple-100 p-8 text-center">
<p className="text-sm text-charcoal-muted mb-4">
You & <span className="font-semibold text-charcoal">{result.friend_name}</span>
</p>
<ScoreCircle score={result.compatibility_score} />
</div>
{/* AI Insight */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100/50 rounded-2xl border border-purple-200 p-6">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-purple mt-0.5 flex-shrink-0" />
<p className="text-charcoal leading-relaxed">{result.insight}</p>
</div>
</div>
{/* Shared Genres */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
<h3 className="font-semibold text-charcoal flex items-center gap-2">
<Heart className="w-4 h-4 text-purple" />
Shared Genres
</h3>
<GenrePills genres={result.shared_genres} color="bg-purple-100 text-purple" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<div>
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only You</h4>
<GenrePills genres={result.unique_to_you} color="bg-blue-100 text-blue-700" />
</div>
<div>
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only Them</h4>
<GenrePills genres={result.unique_to_them} color="bg-amber-100 text-amber-700" />
</div>
</div>
</div>
{/* Shared Artists */}
{result.shared_artists.length > 0 && (
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h3 className="font-semibold text-charcoal flex items-center gap-2 mb-3">
<Music className="w-4 h-4 text-purple" />
Shared Artists
</h3>
<div className="flex flex-wrap gap-2">
{result.shared_artists.map((a) => (
<span
key={a}
className="px-3 py-1.5 bg-cream rounded-lg text-sm text-charcoal font-medium"
>
{a}
</span>
))}
</div>
</div>
)}
{/* Recommendations */}
{result.recommendations.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold text-charcoal text-lg">
Songs You'd Both Love
</h3>
<div className="grid gap-3">
{result.recommendations.map((rec, i) => (
<div
key={i}
className="bg-white rounded-xl border border-purple-100 p-4 flex items-start gap-4"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Music className="w-5 h-5 text-purple" />
</div>
<div className="min-w-0">
<p className="font-medium text-charcoal truncate">{rec.title}</p>
<p className="text-sm text-charcoal-muted">{rec.artist}</p>
<p className="text-xs text-charcoal-muted/80 mt-1">{rec.reason}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}