diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx
index 325321da0..bb7981c56 100644
--- a/components/Home/HomePage.tsx
+++ b/components/Home/HomePage.tsx
@@ -4,6 +4,7 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit";
import { Chat } from "../VercelChat/chat";
import { useEffect } from "react";
import { UIMessage } from "ai";
+import OnboardingModal from "@/components/Onboarding/OnboardingModal";
const HomePage = ({
id,
@@ -22,6 +23,7 @@ const HomePage = ({
return (
+
);
diff --git a/components/Onboarding/OnboardingArtistsStep.tsx b/components/Onboarding/OnboardingArtistsStep.tsx
new file mode 100644
index 000000000..1ba8fe5f0
--- /dev/null
+++ b/components/Onboarding/OnboardingArtistsStep.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { X, Music2, Search, Loader2 } from "lucide-react";
+import { OnboardingNavButtons } from "./OnboardingNavButtons";
+import { getRoleConfig } from "./onboardingRoleConfig";
+import { useSpotifyArtistSearch, type SpotifyArtist } from "./useSpotifyArtistSearch";
+import { useState } from "react";
+
+export interface ArtistEntry {
+ name: string;
+ spotifyUrl?: string;
+ imageUrl?: string;
+}
+
+interface Props {
+ artists: ArtistEntry[];
+ onUpdate: (artists: ArtistEntry[]) => void;
+ onNext: () => void;
+ onBack: () => void;
+ roleType?: string;
+}
+
+/**
+ * Artist step — live Spotify search with avatars, manual fallback.
+ */
+export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleType }: Props) {
+ const { query, setQuery, results, searching, clearResults } = useSpotifyArtistSearch();
+ const [focused, setFocused] = useState(false);
+
+ const { artistPlaceholder } = getRoleConfig(roleType);
+
+ const addFromSpotify = (a: SpotifyArtist) => {
+ if (artists.some(x => x.spotifyUrl === a.external_urls.spotify)) return;
+ onUpdate([
+ ...artists,
+ {
+ name: a.name,
+ spotifyUrl: a.external_urls.spotify,
+ imageUrl: a.images?.[0]?.url,
+ },
+ ]);
+ clearResults();
+ };
+
+ const addManual = () => {
+ const trimmed = query.trim();
+ if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return;
+ onUpdate([...artists, { name: trimmed }]);
+ clearResults();
+ };
+
+ const remove = (idx: number) => onUpdate(artists.filter((_, i) => i !== idx));
+
+ const showDropdown = focused && (results.length > 0 || (searching && query.length > 1));
+ const showManualAdd = focused && query.trim().length > 1 && !searching && results.length === 0;
+
+ return (
+
+
+
Add your priority artists
+
+ We'll run deep research — fan data, release windows, competitive benchmarks
+ — before you ever open a chat.
+
+
+
+ {/* Search input */}
+
+
+
+ setQuery(e.target.value)}
+ onFocus={() => setFocused(true)}
+ onBlur={() => setTimeout(() => setFocused(false), 150)}
+ onKeyDown={e => {
+ if (e.key === "Enter" && results.length > 0) addFromSpotify(results[0]);
+ else if (e.key === "Enter" && query.trim()) addManual();
+ }}
+ className="pl-9 pr-9"
+ />
+ {searching && (
+
+ )}
+
+
+ {/* Dropdown */}
+ {showDropdown && (
+
+ {results.map(a => (
+
+ ))}
+
+ )}
+
+ {/* Manual add fallback */}
+ {showManualAdd && (
+
+
+
+ )}
+
+
+ {/* Added artists */}
+ {artists.length > 0 && (
+
+ {artists.map((a, i) => (
+ -
+
+
+
{a.name}
+ {a.spotifyUrl && (
+
Spotify connected ✓
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ 0
+ ? `Research ${artists.length} artist${artists.length > 1 ? "s" : ""} →`
+ : "Add at least one artist"
+ }
+ />
+ {artists.length === 0 && (
+
+ )}
+
+
+ );
+}
+
+/** Small helper components scoped to this file */
+
+function ArtistAvatar({ imageUrl, name }: { imageUrl?: string; name?: string }) {
+ return imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+
+
+ );
+}
+
+function ArtistSearchResult({
+ artist,
+ onSelect,
+}: {
+ artist: SpotifyArtist;
+ onSelect: (a: SpotifyArtist) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx
new file mode 100644
index 000000000..ceb9b4f88
--- /dev/null
+++ b/components/Onboarding/OnboardingCompleteStep.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { motion, AnimatePresence } from "motion/react";
+import { Button } from "@/components/ui/button";
+import { OnboardingConfetti } from "./OnboardingConfetti";
+
+interface Props {
+ artistNames: string[];
+ name: string | undefined;
+ connectedCount: number;
+ pulseEnabled: boolean;
+ onComplete: () => void;
+}
+
+/**
+ * The "aha moment" — everything summarized with Framer Motion reveals + confetti.
+ */
+export function OnboardingCompleteStep({
+ artistNames,
+ name,
+ connectedCount,
+ pulseEnabled,
+ onComplete,
+}: Props) {
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const t = setTimeout(() => setVisible(true), 100);
+ return () => clearTimeout(t);
+ }, []);
+
+ const summaryItems = [
+ artistNames.length > 0 && {
+ icon: "🎤",
+ text: `Deep research running on ${artistNames.slice(0, 2).join(" & ")}${
+ artistNames.length > 2 ? ` +${artistNames.length - 2} more` : ""
+ }`,
+ },
+ connectedCount > 0 && {
+ icon: "🔗",
+ text: `${connectedCount} platform${connectedCount > 1 ? "s" : ""} connected`,
+ },
+ pulseEnabled && {
+ icon: "⚡",
+ text: "Pulse active — briefing arrives tomorrow morning",
+ },
+ { icon: "✅", text: "First week of tasks queued" },
+ { icon: "🧠", text: "AI learning your artists and fans right now" },
+ ].filter(Boolean) as { icon: string; text: string }[];
+
+ return (
+
+ {visible && (
+ <>
+
+
+ {/* Trophy */}
+
+ 🚀
+
+
+
+
+ {name ? `${name.split(" ")[0]}, you're already ahead.` : "You're already ahead."}
+
+
+ While competitors are guessing, you have AI running intelligence on every move.
+
+
+
+ {/* Summary items */}
+
+ {summaryItems.map((item, i) => (
+
+ {item.icon}
+ {item.text}
+
+ ))}
+
+
+
+
+
+ Your peers in music will want to know what you're using.
+ You don't have to tell them.
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/Onboarding/OnboardingConfetti.tsx b/components/Onboarding/OnboardingConfetti.tsx
new file mode 100644
index 000000000..5534ba9d9
--- /dev/null
+++ b/components/Onboarding/OnboardingConfetti.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { motion } from "motion/react";
+
+const COLORS = ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f97316", "#facc15"];
+const COUNT = 60;
+
+interface Particle {
+ id: number;
+ x: number;
+ color: string;
+ size: number;
+ delay: number;
+ duration: number;
+ rotateEnd: number;
+}
+
+function makeParticles(): Particle[] {
+ return Array.from({ length: COUNT }, (_, i) => ({
+ id: i,
+ x: Math.random() * 100,
+ color: COLORS[Math.floor(Math.random() * COLORS.length)],
+ size: 6 + Math.random() * 6,
+ delay: Math.random() * 0.5,
+ duration: 1.2 + Math.random() * 0.8,
+ rotateEnd: (Math.random() - 0.5) * 720,
+ }));
+}
+
+/**
+ * Lightweight CSS confetti using Framer Motion — no external package needed.
+ * Particles rain down from the top of the viewport.
+ */
+export function OnboardingConfetti() {
+ const [particles] = useState(makeParticles);
+ const [visible, setVisible] = useState(true);
+
+ useEffect(() => {
+ const t = setTimeout(() => setVisible(false), 2500);
+ return () => clearTimeout(t);
+ }, []);
+
+ if (!visible) return null;
+
+ return (
+
+ {particles.map(p => (
+ 0.5 ? "50%" : "2px",
+ backgroundColor: p.color,
+ }}
+ initial={{ y: 0, opacity: 1, rotate: 0, scale: 1 }}
+ animate={{
+ y: "110vh",
+ opacity: [1, 1, 0],
+ rotate: p.rotateEnd,
+ scale: [1, 1, 0.5],
+ }}
+ transition={{
+ duration: p.duration,
+ delay: p.delay,
+ ease: "easeIn",
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx
new file mode 100644
index 000000000..31e156ac8
--- /dev/null
+++ b/components/Onboarding/OnboardingConnectionsStep.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Loader2, CheckCircle2, ExternalLink } from "lucide-react";
+import { OnboardingNavButtons } from "./OnboardingNavButtons";
+import { cn } from "@/lib/utils";
+import { useAccessToken } from "@/hooks/useAccessToken";
+import { authorizeConnectorApi } from "@/lib/composio/api/authorizeConnectorApi";
+
+const FEATURED_CONNECTORS = [
+ {
+ slug: "googlesheets",
+ name: "Google Sheets",
+ icon: "📊",
+ benefit: "Sync fan data and tour reports automatically",
+ },
+ {
+ slug: "googledrive",
+ name: "Google Drive",
+ icon: "📁",
+ benefit: "Access EPKs, assets and files from your AI",
+ },
+ {
+ slug: "tiktok",
+ name: "TikTok",
+ icon: "🎵",
+ benefit: "Track viral moments and creator content",
+ },
+ {
+ slug: "googledocs",
+ name: "Google Docs",
+ icon: "📝",
+ benefit: "Auto-draft press releases and pitch docs",
+ },
+];
+
+interface Props {
+ connected: string[];
+ onConnect: (slug: string) => void;
+ onNext: () => void;
+ onBack: () => void;
+}
+
+/**
+ * Step to connect key platform integrations during onboarding.
+ */
+export function OnboardingConnectionsStep({ connected, onConnect, onNext, onBack }: Props) {
+ const accessToken = useAccessToken();
+ const [connecting, setConnecting] = useState(null);
+
+ const handleConnect = async (slug: string) => {
+ if (!accessToken) return;
+ setConnecting(slug);
+ try {
+ const url = await authorizeConnectorApi(accessToken, {
+ connector: slug,
+ callbackUrl: window.location.href,
+ });
+ if (url) {
+ onConnect(slug);
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ } catch {
+ // silently continue
+ } finally {
+ setConnecting(null);
+ }
+ };
+
+ return (
+
+
+
+ Connect your tools
+
+
+ Give Recoupable access to the places your work already lives.
+ Connect what you want — skip the rest.
+
+
+
+
+ {FEATURED_CONNECTORS.map(c => {
+ const isConnected = connected.includes(c.slug);
+ const isConnecting = connecting === c.slug;
+
+ return (
+
+
{c.icon}
+
+
{c.name}
+
{c.benefit}
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+
0 ? `Continue with ${connected.length} connected →` : "Skip for now →"}
+ />
+
+ More connectors available in Settings → Connectors
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingContextStep.tsx b/components/Onboarding/OnboardingContextStep.tsx
new file mode 100644
index 000000000..b2191ab4e
--- /dev/null
+++ b/components/Onboarding/OnboardingContextStep.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useEffect } from "react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useUserProvider } from "@/providers/UserProvder";
+import { OnboardingNavButtons } from "./OnboardingNavButtons";
+import { getRoleConfig } from "./onboardingRoleConfig";
+
+interface Props {
+ name: string | undefined;
+ companyName: string | undefined;
+ roleType: string | undefined;
+ onChangeName: (v: string) => void;
+ onChangeCompany: (v: string) => void;
+ onNext: () => void;
+ onBack: () => void;
+}
+
+/**
+ * Context step — pre-fills name from Privy, adapts company label to role.
+ */
+export function OnboardingContextStep({
+ name,
+ companyName,
+ roleType,
+ onChangeName,
+ onChangeCompany,
+ onNext,
+ onBack,
+}: Props) {
+ const { userData, email } = useUserProvider();
+
+ // Pre-fill name from account data or email
+ useEffect(() => {
+ if (!name && (userData?.name || email)) {
+ const inferred =
+ userData?.name ||
+ (email
+ ? email
+ .split("@")[0]
+ .replace(/[._]/g, " ")
+ .replace(/\b\w/g, c => c.toUpperCase())
+ : "");
+ if (inferred) onChangeName(inferred);
+ }
+ }, [userData?.name, email]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const { companyLabel } = getRoleConfig(roleType);
+
+ return (
+
+
+
Quick intro
+
+ We'll use this to personalize your workspace.
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx
new file mode 100644
index 000000000..86ec0a478
--- /dev/null
+++ b/components/Onboarding/OnboardingModal.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useCallback, useEffect } from "react";
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+import { useOnboarding, type OnboardingStep } from "./useOnboarding";
+import { useOnboardingPersist } from "./useOnboardingPersist";
+import { OnboardingWelcomeStep } from "./OnboardingWelcomeStep";
+import { OnboardingRoleStep } from "./OnboardingRoleStep";
+import { OnboardingContextStep } from "./OnboardingContextStep";
+import { OnboardingArtistsStep } from "./OnboardingArtistsStep";
+import { OnboardingConnectionsStep } from "./OnboardingConnectionsStep";
+import { OnboardingPulseStep } from "./OnboardingPulseStep";
+import { OnboardingTasksStep } from "./OnboardingTasksStep";
+import { OnboardingCompleteStep } from "./OnboardingCompleteStep";
+import { OnboardingStepDots } from "./OnboardingStepDots";
+
+const PROGRESS_STEPS: OnboardingStep[] = ["role", "context", "artists", "connections", "pulse", "tasks"];
+
+/**
+ * Onboarding wizard orchestrator.
+ * Delegates persistence to useOnboardingPersist and step state to useOnboarding.
+ */
+export default function OnboardingModal() {
+ const { isOpen, step, data, updateData, nextStep, prevStep, complete } = useOnboarding();
+ const { persist } = useOnboardingPersist();
+
+ // Persist everything when the tasks (penultimate) step is reached
+ useEffect(() => {
+ if (step === "tasks") {
+ persist(data);
+ }
+ }, [step]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleComplete = useCallback(async () => {
+ await complete();
+ const artistNames = (data.artists ?? []).map(a => a.name);
+ if (artistNames.length > 0) {
+ const q = encodeURIComponent(
+ `Give me a complete status report for ${artistNames[0]} — fan breakdown, streaming performance, top opportunities, and my 3 highest-priority actions this week.`,
+ );
+ setTimeout(() => { window.location.href = `/?q=${q}`; }, 200);
+ }
+ }, [complete, data.artists]);
+
+ const isProgressStep = PROGRESS_STEPS.includes(step as OnboardingStep);
+
+ return (
+
+ );
+}
diff --git a/components/Onboarding/OnboardingNavButtons.tsx b/components/Onboarding/OnboardingNavButtons.tsx
new file mode 100644
index 000000000..754e030c8
--- /dev/null
+++ b/components/Onboarding/OnboardingNavButtons.tsx
@@ -0,0 +1,29 @@
+import { Button } from "@/components/ui/button";
+
+interface OnboardingNavButtonsProps {
+ onBack: () => void;
+ onNext: () => void;
+ nextLabel?: string;
+ nextDisabled?: boolean;
+}
+
+/**
+ * Reusable back/next navigation row used across onboarding steps.
+ */
+export function OnboardingNavButtons({
+ onBack,
+ onNext,
+ nextLabel = "Continue →",
+ nextDisabled = false,
+}: OnboardingNavButtonsProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingPulseStep.tsx b/components/Onboarding/OnboardingPulseStep.tsx
new file mode 100644
index 000000000..08638be3c
--- /dev/null
+++ b/components/Onboarding/OnboardingPulseStep.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { OnboardingNavButtons } from "./OnboardingNavButtons";
+
+const PULSE_BENEFITS = [
+ { icon: "📈", text: "Daily streaming performance summaries" },
+ { icon: "🔔", text: "Alerts when an artist spikes on social" },
+ { icon: "🎯", text: "Weekly priority actions for each artist" },
+ { icon: "📬", text: "Delivered to your inbox every morning" },
+];
+
+interface Props {
+ enabled: boolean;
+ onToggle: (v: boolean) => void;
+ onNext: () => void;
+ onBack: () => void;
+}
+
+/**
+ * Onboarding step to activate Pulse — daily AI briefings per artist.
+ */
+export function OnboardingPulseStep({ enabled, onToggle, onNext, onBack }: Props) {
+ return (
+
+
+
+ ⚡
+
Turn on Pulse
+
+
+ Pulse sends you an AI-generated daily briefing on each of your artists —
+ so you always know what to do next before you even open the app.
+
+
+
+ {/* Toggle card */}
+
onToggle(!enabled)}
+ >
+
+
+
Daily Artist Intelligence
+
+ {enabled ? "Active — you'll get your first briefing tomorrow morning." : "Off — tap to activate."}
+
+
+
e.stopPropagation()} />
+
+
+
+ {/* Benefits list */}
+
+ {PULSE_BENEFITS.map((b, i) => (
+
+ {b.icon}
+ {b.text}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingRoleStep.tsx b/components/Onboarding/OnboardingRoleStep.tsx
new file mode 100644
index 000000000..68e995fa7
--- /dev/null
+++ b/components/Onboarding/OnboardingRoleStep.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { motion } from "motion/react";
+import { ONBOARDING_ROLES } from "./onboardingRoleConfig";
+
+interface Props {
+ selected: string | undefined;
+ onSelect: (role: string) => void;
+ onNext: () => void;
+}
+
+export function OnboardingRoleStep({ selected, onSelect, onNext }: Props) {
+ return (
+
+
+ What best describes you?
+
+ We'll personalize your entire workspace to your world.
+
+
+
+
+ {ONBOARDING_ROLES.map((role, i) => (
+ onSelect(role.id)}
+ initial={{ opacity: 0, y: 8 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: i * 0.05 }}
+ className={cn(
+ "flex flex-col items-start gap-1 rounded-xl border-2 p-4 text-left transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ selected === role.id
+ ? "border-primary bg-primary/10"
+ : "border-border bg-muted/30",
+ )}
+ >
+ {role.icon}
+ {role.label}
+ {role.description}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingStepDots.tsx b/components/Onboarding/OnboardingStepDots.tsx
new file mode 100644
index 000000000..11651b4ea
--- /dev/null
+++ b/components/Onboarding/OnboardingStepDots.tsx
@@ -0,0 +1,58 @@
+import { cn } from "@/lib/utils";
+import type { OnboardingStep } from "./useOnboarding";
+
+const STEPS: { id: OnboardingStep; label: string }[] = [
+ { id: "role", label: "Role" },
+ { id: "context", label: "You" },
+ { id: "artists", label: "Artists" },
+ { id: "connections", label: "Connect" },
+ { id: "pulse", label: "Pulse" },
+ { id: "tasks", label: "Tasks" },
+];
+
+interface Props {
+ current: OnboardingStep;
+}
+
+/**
+ * Visual step progress dots with labels for the onboarding wizard.
+ */
+export function OnboardingStepDots({ current }: Props) {
+ const currentIdx = STEPS.findIndex(s => s.id === current);
+
+ return (
+
+ {STEPS.map((step, i) => {
+ const isCompleted = i < currentIdx;
+ const isCurrent = i === currentIdx;
+
+ return (
+
+ {/* Dot */}
+
+ {/* Connector line */}
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/components/Onboarding/OnboardingTasksStep.tsx b/components/Onboarding/OnboardingTasksStep.tsx
new file mode 100644
index 000000000..7439d7273
--- /dev/null
+++ b/components/Onboarding/OnboardingTasksStep.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+import { OnboardingNavButtons } from "./OnboardingNavButtons";
+
+type RoleTaskMap = Record;
+
+const ROLE_TASKS: RoleTaskMap = {
+ artist_manager: [
+ { icon: "📊", title: "Fan segment deep dive", desc: "Identify top 3 growth markets for each artist" },
+ { icon: "📅", title: "Release window analysis", desc: "Find the best drop timing based on streaming patterns" },
+ { icon: "🤝", title: "Playlist pitch list", desc: "Build a targeted pitch list for the next release" },
+ { icon: "📱", title: "Social content calendar", desc: "Generate a 2-week content plan per platform" },
+ ],
+ label: [
+ { icon: "📈", title: "Roster performance report", desc: "Weekly streaming & social benchmarks across roster" },
+ { icon: "💰", title: "Sync opportunity scan", desc: "Match catalog tracks to current placement briefs" },
+ { icon: "🎯", title: "Market expansion analysis", desc: "Find untapped territories for priority artists" },
+ { icon: "📋", title: "A&R brief generator", desc: "Auto-draft artist development briefs from data" },
+ ],
+ artist: [
+ { icon: "🎵", title: "Release strategy builder", desc: "Plan your next drop from concept to launch" },
+ { icon: "📊", title: "Audience growth playbook", desc: "Tailored strategy based on your current fanbase" },
+ { icon: "💬", title: "Press outreach list", desc: "Curated list of blogs, editors, and tastemakers" },
+ { icon: "📱", title: "Content idea generator", desc: "30 days of social content ideas from your catalog" },
+ ],
+ publisher: [
+ { icon: "🔍", title: "Sync pitch tracker", desc: "Track placement opportunities by catalog title" },
+ { icon: "📊", title: "Royalty performance scan", desc: "Flag underperforming assets for renegotiation" },
+ { icon: "🤝", title: "Sub-publisher match", desc: "Find territory reps based on catalog genre profile" },
+ { icon: "📝", title: "Admin review checklist", desc: "Audit registration completeness across catalog" },
+ ],
+ dsp: [
+ { icon: "🎯", title: "Emerging artist radar", desc: "Surface breakout acts based on velocity signals" },
+ { icon: "📊", title: "Genre trend report", desc: "Identify rising micro-genres ahead of the curve" },
+ { icon: "🤝", title: "Label partnership brief", desc: "Pitch deck framework for priority label meetings" },
+ { icon: "🔔", title: "Cultural moment tracker", desc: "Link playlisting opportunities to trending moments" },
+ ],
+ other: [
+ { icon: "🗓️", title: "Weekly priorities briefing", desc: "AI-curated to-do list based on your artists" },
+ { icon: "📊", title: "Artist performance snapshot", desc: "At-a-glance metrics across all your artists" },
+ { icon: "💡", title: "Opportunity alerts", desc: "Notify you when an artist hits a key milestone" },
+ { icon: "📝", title: "Smart notes assistant", desc: "Turn meeting notes into action items instantly" },
+ ],
+};
+
+const DEFAULT_TASKS = ROLE_TASKS["artist_manager"];
+
+interface Props {
+ roleType: string | undefined;
+ artistNames: string[];
+ onNext: () => void;
+ onBack: () => void;
+}
+
+/**
+ * Shows auto-generated tasks tailored to the user's role + artists,
+ * giving them an immediate preview of what Recoupable will do for them.
+ */
+export function OnboardingTasksStep({ roleType, artistNames, onNext, onBack }: Props) {
+ const tasks = ROLE_TASKS[roleType ?? ""] ?? DEFAULT_TASKS;
+ const [revealed, setRevealed] = useState([]);
+
+ useEffect(() => {
+ let i = 0;
+ const interval = setInterval(() => {
+ setRevealed(prev => [...prev, i]);
+ i++;
+ if (i >= tasks.length) clearInterval(interval);
+ }, 180);
+ return () => clearInterval(interval);
+ }, [tasks.length]);
+
+ const displayArtist = artistNames[0] ?? "your artists";
+
+ return (
+
+
+
+ Your first week, already planned
+
+
+ Based on your role and{" "}
+ {displayArtist},
+ we've queued these to run automatically.
+
+
+
+
+ {tasks.map((task, i) => (
+
+
{task.icon}
+
+
{task.title}
+
{task.desc}
+
+
+ Queued ✓
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/OnboardingWelcomeStep.tsx b/components/Onboarding/OnboardingWelcomeStep.tsx
new file mode 100644
index 000000000..4136a98af
--- /dev/null
+++ b/components/Onboarding/OnboardingWelcomeStep.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { motion, AnimatePresence } from "motion/react";
+import { useUserProvider } from "@/providers/UserProvder";
+
+const RESEARCH_LINES = [
+ "Identifying your industry footprint…",
+ "Scanning music business context…",
+ "Preparing your personalized workspace…",
+ "Queueing artist intelligence tasks…",
+ "Almost ready to change your workflow forever…",
+];
+
+const SOCIAL_PROOF = [
+ "10,000+ artists managed",
+ "50+ labels onboarded",
+ "Used by teams at UMG, Def Jam & more",
+];
+
+interface Props {
+ onDone: () => void;
+}
+
+/**
+ * Animated welcome screen — uses Privy name/email, shows social proof, auto-advances.
+ */
+export function OnboardingWelcomeStep({ onDone }: Props) {
+ const { email, userData } = useUserProvider();
+ const [lineIdx, setLineIdx] = useState(0);
+ const [done, setDone] = useState(false);
+
+ // Infer first name from email or account name
+ const firstName =
+ userData?.name?.split(" ")[0] ||
+ (email ? email.split("@")[0].replace(/[._]/g, " ").split(" ")[0] : null);
+
+ // Capitalize first letter
+ const displayName = firstName
+ ? firstName.charAt(0).toUpperCase() + firstName.slice(1)
+ : null;
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setLineIdx(prev => {
+ const next = prev + 1;
+ if (next >= RESEARCH_LINES.length) {
+ clearInterval(interval);
+ setDone(true);
+ setTimeout(onDone, 700);
+ return prev;
+ }
+ return next;
+ });
+ }, 520);
+ return () => clearInterval(interval);
+ }, [onDone]);
+
+ return (
+
+ {/* Animated orb */}
+
+
+ {/* Headline */}
+
+
+ {displayName
+ ? `Hey ${displayName} — welcome to Recoupable.`
+ : "Welcome to Recoupable."}
+
+
+ The AI platform built for people who actually move music.
+
+
+
+ {/* Social proof */}
+
+ {SOCIAL_PROOF.map((s, i) => (
+
+ {s}
+
+ ))}
+
+
+ {/* Animated research lines */}
+
+
+ {RESEARCH_LINES.map((line, i) =>
+ i <= lineIdx ? (
+
+
+ {i < lineIdx ? "✅" : "⏳"}
+
+
+ {line}
+
+
+ ) : null,
+ )}
+
+
+
+ {done && (
+
+ Let's set you up →
+
+ )}
+
+ );
+}
diff --git a/components/Onboarding/onboardingRoleConfig.ts b/components/Onboarding/onboardingRoleConfig.ts
new file mode 100644
index 000000000..ca213553f
--- /dev/null
+++ b/components/Onboarding/onboardingRoleConfig.ts
@@ -0,0 +1,81 @@
+/**
+ * Single source of truth for all role-related configuration used across
+ * the onboarding flow. Import from here instead of defining per-file.
+ */
+
+export interface RoleConfig {
+ id: string;
+ label: string;
+ icon: string;
+ description: string;
+ companyLabel: string;
+ artistPlaceholder: string;
+}
+
+export const ONBOARDING_ROLES: RoleConfig[] = [
+ {
+ id: "artist_manager",
+ label: "Artist Manager",
+ icon: "🎯",
+ description: "I manage one or more artists",
+ companyLabel: "Management company",
+ artistPlaceholder: "Search for an artist you manage…",
+ },
+ {
+ id: "label",
+ label: "Record Label",
+ icon: "🏷️",
+ description: "I run a label or imprint",
+ companyLabel: "Label name",
+ artistPlaceholder: "Search for a roster artist…",
+ },
+ {
+ id: "artist",
+ label: "Artist",
+ icon: "🎤",
+ description: "I'm the artist",
+ companyLabel: "Your artist name or team",
+ artistPlaceholder: "Search for yourself or a collaborator…",
+ },
+ {
+ id: "publisher",
+ label: "Publisher",
+ icon: "📝",
+ description: "I handle publishing & sync",
+ companyLabel: "Publishing company",
+ artistPlaceholder: "Search for a catalog artist…",
+ },
+ {
+ id: "dsp",
+ label: "DSP / Platform",
+ icon: "📱",
+ description: "I work at a streaming platform",
+ companyLabel: "Company / Platform",
+ artistPlaceholder: "Search for an artist…",
+ },
+ {
+ id: "other",
+ label: "Other",
+ icon: "✨",
+ description: "Something else entirely",
+ companyLabel: "Company or organization",
+ artistPlaceholder: "Search for an artist…",
+ },
+];
+
+export const ROLE_CONFIG_MAP = Object.fromEntries(
+ ONBOARDING_ROLES.map(r => [r.id, r]),
+) as Record;
+
+export function getRoleConfig(roleId: string | undefined): RoleConfig {
+ return (
+ ROLE_CONFIG_MAP[roleId ?? ""] ?? {
+ id: "other",
+ label: "Other",
+ icon: "✨",
+ description: "",
+ companyLabel: "Company",
+ artistPlaceholder: "Search for an artist…",
+ }
+ );
+}
diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts
new file mode 100644
index 000000000..6a2e763b8
--- /dev/null
+++ b/components/Onboarding/useOnboarding.ts
@@ -0,0 +1,87 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useUserProvider } from "@/providers/UserProvder";
+
+export type OnboardingStep =
+ | "welcome" // Step 0: Animated welcome / research-in-progress on the user
+ | "role" // Step 1: Who are you?
+ | "context" // Step 2: Name, company, vibe
+ | "artists" // Step 3: Priority artists
+ | "connections" // Step 4: Connect key platforms
+ | "pulse" // Step 5: Turn on Pulse
+ | "tasks" // Step 6: Preview auto-generated tasks
+ | "complete"; // Step 7: Aha moment reveal
+
+export interface OnboardingArtist {
+ name: string;
+ spotifyUrl?: string;
+}
+
+export interface OnboardingData {
+ roleType: string;
+ companyName: string;
+ name: string;
+ artists: OnboardingArtist[];
+ connectedSlugs: string[];
+ pulseEnabled: boolean;
+}
+
+const STEP_ORDER: OnboardingStep[] = [
+ "welcome",
+ "role",
+ "context",
+ "artists",
+ "connections",
+ "pulse",
+ "tasks",
+ "complete",
+];
+
+/**
+ * Drives the onboarding wizard.
+ * Fires once for new accounts (onboarding_status not completed).
+ */
+export function useOnboarding() {
+ const { userData } = useUserProvider();
+ const [isOpen, setIsOpen] = useState(false);
+ const [step, setStep] = useState("welcome");
+ const [data, setData] = useState>({
+ artists: [],
+ connectedSlugs: [],
+ pulseEnabled: false,
+ });
+
+ useEffect(() => {
+ if (!userData) return;
+ const status = userData.onboarding_status as Record | null;
+ if (!status?.completed) {
+ setIsOpen(true);
+ }
+ }, [userData]);
+
+ const updateData = useCallback((patch: Partial) => {
+ setData(prev => ({ ...prev, ...patch }));
+ }, []);
+
+ const nextStep = useCallback(() => {
+ setStep(prev => {
+ const idx = STEP_ORDER.indexOf(prev);
+ return STEP_ORDER[idx + 1] ?? "complete";
+ });
+ }, []);
+
+ const prevStep = useCallback(() => {
+ setStep(prev => {
+ const idx = STEP_ORDER.indexOf(prev);
+ if (idx <= 1) return prev; // can't go before "role"
+ return STEP_ORDER[idx - 1];
+ });
+ }, []);
+
+ const complete = useCallback(() => {
+ setIsOpen(false);
+ }, []);
+
+ return { isOpen, step, data, updateData, nextStep, prevStep, complete };
+}
diff --git a/components/Onboarding/useOnboardingPersist.ts b/components/Onboarding/useOnboardingPersist.ts
new file mode 100644
index 000000000..2779b1b47
--- /dev/null
+++ b/components/Onboarding/useOnboardingPersist.ts
@@ -0,0 +1,74 @@
+"use client";
+
+import { useCallback } from "react";
+import { useUserProvider } from "@/providers/UserProvder";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import { useAccessToken } from "@/hooks/useAccessToken";
+import saveArtist from "@/lib/saveArtist";
+import { updatePulse } from "@/lib/pulse/updatePulse";
+import type { OnboardingData } from "./useOnboarding";
+
+/**
+ * Handles all side-effects when the onboarding wizard completes:
+ * - Creates priority artists in the DB
+ * - Activates Pulse if the user opted in
+ * - Persists role, name, company, and onboarding status to account_info
+ * - Refreshes the artist sidebar
+ */
+export function useOnboardingPersist() {
+ const { userData } = useUserProvider();
+ const { getArtists } = useArtistProvider();
+ const accessToken = useAccessToken();
+
+ const persist = useCallback(
+ async (data: Partial) => {
+ if (!userData?.account_id) return;
+
+ // Create artists in parallel
+ const artistPromises = (data.artists ?? []).map(a =>
+ saveArtist({
+ name: a.name,
+ spotifyUrl: a.spotifyUrl,
+ accountId: userData.account_id,
+ }).catch(console.error),
+ );
+ await Promise.allSettled(artistPromises);
+
+ // Activate Pulse if opted in
+ if (data.pulseEnabled && accessToken) {
+ await updatePulse({ accessToken, active: true }).catch(console.error);
+ }
+
+ // Persist account info + onboarding status
+ await fetch("/api/account/update", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ accountId: userData.account_id,
+ roleType: data.roleType,
+ name: data.name,
+ companyName: data.companyName,
+ onboardingStatus: {
+ completed: true,
+ completedAt: new Date().toISOString(),
+ steps: {
+ role: data.roleType,
+ artistCount: (data.artists ?? []).length,
+ connectedCount: (data.connectedSlugs ?? []).length,
+ pulseEnabled: data.pulseEnabled,
+ },
+ },
+ onboardingData: data,
+ }),
+ }).catch(console.error);
+
+ // Refresh artist sidebar
+ if (typeof getArtists === "function") {
+ await getArtists().catch(console.error);
+ }
+ },
+ [userData?.account_id, accessToken, getArtists],
+ );
+
+ return { persist };
+}
diff --git a/components/Onboarding/useSpotifyArtistSearch.ts b/components/Onboarding/useSpotifyArtistSearch.ts
new file mode 100644
index 000000000..931867440
--- /dev/null
+++ b/components/Onboarding/useSpotifyArtistSearch.ts
@@ -0,0 +1,66 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { NEW_API_BASE_URL } from "@/lib/consts";
+import { useAccessToken } from "@/hooks/useAccessToken";
+
+export interface SpotifyArtist {
+ id: string;
+ name: string;
+ external_urls: { spotify: string };
+ images: { url: string }[];
+ followers: { total: number };
+}
+
+/**
+ * Debounced Spotify artist search hook.
+ * Encapsulates all search state, debouncing, and API fetching logic.
+ */
+export function useSpotifyArtistSearch() {
+ const accessToken = useAccessToken();
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+ const debounceRef = useRef | null>(null);
+
+ const search = useCallback(
+ async (q: string) => {
+ if (!q.trim() || q.length < 2) {
+ setResults([]);
+ return;
+ }
+ setSearching(true);
+ try {
+ const params = new URLSearchParams({ q, type: "artist", limit: "5" });
+ const url = `${NEW_API_BASE_URL}/api/spotify/search?${params}`;
+ const headers: HeadersInit = accessToken
+ ? { Authorization: `Bearer ${accessToken}` }
+ : {};
+ const res = await fetch(url, { headers });
+ if (!res.ok) throw new Error("search failed");
+ const data = await res.json();
+ setResults(data?.artists?.items ?? []);
+ } catch {
+ setResults([]);
+ } finally {
+ setSearching(false);
+ }
+ },
+ [accessToken],
+ );
+
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => search(query), 320);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [query, search]);
+
+ const clearResults = useCallback(() => {
+ setResults([]);
+ setQuery("");
+ }, []);
+
+ return { query, setQuery, results, searching, clearResults };
+}