From d88963caf7d3e75346157f615f403d28e306b658 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 03:41:45 +0000 Subject: [PATCH 1/7] agent: @U0AJM7X8FBR Chat - we want an onboarding flow that gets the user from --- components/Home/HomePage.tsx | 2 + .../Onboarding/OnboardingArtistsStep.tsx | 144 ++++++++++++++ .../Onboarding/OnboardingCompleteStep.tsx | 75 ++++++++ .../Onboarding/OnboardingContextStep.tsx | 60 ++++++ components/Onboarding/OnboardingModal.tsx | 177 ++++++++++++++++++ .../Onboarding/OnboardingResearchingStep.tsx | 96 ++++++++++ components/Onboarding/OnboardingRoleStep.tsx | 62 ++++++ components/Onboarding/useOnboarding.ts | 63 +++++++ 8 files changed, 679 insertions(+) create mode 100644 components/Onboarding/OnboardingArtistsStep.tsx create mode 100644 components/Onboarding/OnboardingCompleteStep.tsx create mode 100644 components/Onboarding/OnboardingContextStep.tsx create mode 100644 components/Onboarding/OnboardingModal.tsx create mode 100644 components/Onboarding/OnboardingResearchingStep.tsx create mode 100644 components/Onboarding/OnboardingRoleStep.tsx create mode 100644 components/Onboarding/useOnboarding.ts 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..20476df50 --- /dev/null +++ b/components/Onboarding/OnboardingArtistsStep.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { X, Plus, Music2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ArtistEntry { + name: string; + spotifyUrl?: string; +} + +interface Props { + artists: ArtistEntry[]; + onUpdate: (artists: ArtistEntry[]) => void; + onNext: () => void; +} + +export function OnboardingArtistsStep({ artists, onUpdate, onNext }: Props) { + const [newName, setNewName] = useState(""); + const [newUrl, setNewUrl] = useState(""); + const [showUrlField, setShowUrlField] = useState(false); + + const addArtist = () => { + if (!newName.trim()) return; + onUpdate([...artists, { name: newName.trim(), spotifyUrl: newUrl.trim() || undefined }]); + setNewName(""); + setNewUrl(""); + setShowUrlField(false); + }; + + const removeArtist = (idx: number) => { + onUpdate(artists.filter((_, i) => i !== idx)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") addArtist(); + }; + + return ( +
+
+

+ Add your priority artists +

+

+ We'll run deep research on each one — fan segments, release data, competitive + analysis, and proactive tasks — before you ever open a chat. +

+
+ + {/* Artist list */} + {artists.length > 0 && ( + + )} + + {/* Add artist input */} +
+
+ setNewName(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1" + /> + +
+ + + + {showUrlField && ( + setNewUrl(e.target.value)} + onKeyDown={handleKeyDown} + className="text-sm" + /> + )} +
+ +
+ + {artists.length === 0 && ( + + )} +
+
+ ); +} diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx new file mode 100644 index 000000000..1776cc1ab --- /dev/null +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const HIGHLIGHTS = [ + { icon: "🎯", title: "Fan segments mapped", desc: "Know exactly who's listening and where to grow." }, + { icon: "📅", title: "Release window insights", desc: "Best timing to drop based on your fans' activity." }, + { icon: "✅", title: "Proactive tasks queued", desc: "Your first week's to-do list — already written." }, + { icon: "💬", title: "AI artist chat ready", desc: "Ask anything about your artists. Get instant answers." }, +]; + +interface Props { + artistNames: string[]; + name: string | undefined; + onComplete: () => void; +} + +export function OnboardingCompleteStep({ artistNames, name, onComplete }: Props) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setVisible(true), 100); + return () => clearTimeout(t); + }, []); + + return ( +
+ {/* Celebration */} +
+
🎉
+

+ {name ? `${name}, you're all set.` : "You're all set."} +

+

+ {artistNames.length > 0 + ? `Your intelligence files for ${artistNames.slice(0, 2).join(" and ")}${artistNames.length > 2 ? ` (+${artistNames.length - 2} more)` : ""} are ready.` + : "Your Recoupable workspace is ready to go."} +

+
+ + {/* Feature cards */} +
+ {HIGHLIGHTS.map((h, i) => ( +
+ {h.icon} +

{h.title}

+

{h.desc}

+
+ ))} +
+ + + +

+ Your friends will ask how you got so ahead. Tell them. +

+
+ ); +} diff --git a/components/Onboarding/OnboardingContextStep.tsx b/components/Onboarding/OnboardingContextStep.tsx new file mode 100644 index 000000000..35a4d5b68 --- /dev/null +++ b/components/Onboarding/OnboardingContextStep.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface Props { + name: string | undefined; + companyName: string | undefined; + onChangeName: (v: string) => void; + onChangeCompany: (v: string) => void; + onNext: () => void; +} + +export function OnboardingContextStep({ + name, + companyName, + onChangeName, + onChangeCompany, + onNext, +}: Props) { + return ( +
+
+

+ Let's get to know you +

+

+ Just the basics — we'll do the heavy lifting. +

+
+ +
+
+ + onChangeName(e.target.value)} + /> +
+ +
+ + onChangeCompany(e.target.value)} + /> +
+
+ + +
+ ); +} diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx new file mode 100644 index 000000000..5ddfa49f9 --- /dev/null +++ b/components/Onboarding/OnboardingModal.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog"; +import { useOnboarding } from "./useOnboarding"; +import { OnboardingRoleStep } from "./OnboardingRoleStep"; +import { OnboardingContextStep } from "./OnboardingContextStep"; +import { OnboardingArtistsStep } from "./OnboardingArtistsStep"; +import { OnboardingResearchingStep } from "./OnboardingResearchingStep"; +import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; +import { useUserProvider } from "@/providers/UserProvder"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import saveArtist from "@/lib/saveArtist"; + +const STEP_TITLES: Record = { + role: "1 of 3", + context: "2 of 3", + artists: "3 of 3", + researching: "", + complete: "", +}; + +/** + * Full-screen onboarding wizard that fires once for new users. + * Walks through role selection → context → artist setup → research → wow moment. + */ +export default function OnboardingModal() { + const { isOpen, step, data, updateData, nextStep, complete } = useOnboarding(); + const { userData } = useUserProvider(); + const { getArtists } = useArtistProvider(); + + // Persist role + context to account when we reach the artists step + useEffect(() => { + if (step !== "artists" || !userData?.account_id) return; + if (!data.roleType && !data.name) return; + + 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, + }), + }).catch(console.error); + }, [step]); // eslint-disable-line react-hooks/exhaustive-deps + + // When researching starts, actually create artists and persist onboarding_status + useEffect(() => { + if (step !== "researching" || !userData?.account_id) return; + + const run = async () => { + // Create each artist 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); + + // Mark onboarding complete on account_info + await fetch("/api/account/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: userData.account_id, + onboardingStatus: { completed: true, completedAt: new Date().toISOString() }, + onboardingData: data, + }), + }).catch(console.error); + + // Refresh artists list in sidebar + if (typeof getArtists === "function") { + await getArtists().catch(console.error); + } + }; + + run(); + }, [step]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleComplete = useCallback(async () => { + await complete(); + // Kick off a proactive first chat after a short delay + setTimeout(() => { + const artistNames = (data.artists ?? []).map(a => a.name); + if (artistNames.length > 0) { + const q = encodeURIComponent( + `Give me a quick status report and top 3 priorities for ${artistNames[0]} this week`, + ); + window.location.href = `/?q=${q}`; + } + }, 300); + }, [complete, data.artists]); + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={e => e.preventDefault()} + > + {/* Header bar */} + {STEP_TITLES[step] && ( +
+
+ Welcome to Recoupable +
+ {STEP_TITLES[step]} +
+ )} + + {/* Progress bar */} + {["role", "context", "artists"].includes(step) && ( +
+
+
+ )} + + {/* Step content */} +
+ {step === "role" && ( + updateData({ roleType: v })} + onNext={nextStep} + /> + )} + + {step === "context" && ( + updateData({ name: v })} + onChangeCompany={v => updateData({ companyName: v })} + onNext={nextStep} + /> + )} + + {step === "artists" && ( + updateData({ artists })} + onNext={nextStep} + /> + )} + + {step === "researching" && ( + a.name)} + onComplete={nextStep} + /> + )} + + {step === "complete" && ( + a.name)} + name={data.name} + onComplete={handleComplete} + /> + )} +
+ +
+ ); +} diff --git a/components/Onboarding/OnboardingResearchingStep.tsx b/components/Onboarding/OnboardingResearchingStep.tsx new file mode 100644 index 000000000..7b448d2ea --- /dev/null +++ b/components/Onboarding/OnboardingResearchingStep.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +const RESEARCH_STAGES = [ + { label: "Analyzing fan segments & demographics", icon: "📊" }, + { label: "Pulling streaming data & release history", icon: "🎵" }, + { label: "Scanning upcoming tour & release windows", icon: "📅" }, + { label: "Benchmarking against similar artists", icon: "🔍" }, + { label: "Generating proactive task recommendations", icon: "✅" }, + { label: "Building your artist intelligence files", icon: "🧠" }, +]; + +interface Props { + artistNames: string[]; + onComplete: () => void; +} + +export function OnboardingResearchingStep({ artistNames, onComplete }: Props) { + const [currentStage, setCurrentStage] = useState(0); + const [completedStages, setCompletedStages] = useState([]); + + useEffect(() => { + const interval = setInterval(() => { + setCompletedStages(prev => [...prev, currentStage]); + setCurrentStage(prev => { + const next = prev + 1; + if (next >= RESEARCH_STAGES.length) { + clearInterval(interval); + setTimeout(onComplete, 600); + return prev; + } + return next; + }); + }, 900); + + return () => clearInterval(interval); + }, [onComplete]); // eslint-disable-line react-hooks/exhaustive-deps + + const displayArtists = artistNames.slice(0, 3); + const overflow = artistNames.length - 3; + + return ( +
+ {/* Animated logo / pulse */} +
+
+
+
+ 🧠 +
+
+ +
+

+ Running deep research + {displayArtists.length > 0 && ( + <> + {" on "} + + {displayArtists.join(", ")} + {overflow > 0 && ` +${overflow} more`} + + + )} +

+

+ This usually takes just a moment. Get ready to be impressed. +

+
+ + {/* Stage progress */} +
+ {RESEARCH_STAGES.map((stage, i) => ( +
+ + {completedStages.includes(i) ? "✅" : stage.icon} + + {stage.label} +
+ ))} +
+
+ ); +} diff --git a/components/Onboarding/OnboardingRoleStep.tsx b/components/Onboarding/OnboardingRoleStep.tsx new file mode 100644 index 000000000..dc530935a --- /dev/null +++ b/components/Onboarding/OnboardingRoleStep.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +const ROLES = [ + { id: "artist_manager", label: "Artist Manager", icon: "🎯", description: "I manage one or more artists" }, + { id: "label", label: "Record Label", icon: "🏷️", description: "I run a label or imprint" }, + { id: "artist", label: "Artist", icon: "🎤", description: "I'm the artist" }, + { id: "publisher", label: "Publisher", icon: "📝", description: "I handle publishing & sync" }, + { id: "dsp", label: "DSP / Platform", icon: "📱", description: "I work at a streaming platform" }, + { id: "other", label: "Other", icon: "✨", description: "Something else entirely" }, +]; + +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 everything to your world. +

+
+ +
+ {ROLES.map(role => ( + + ))} +
+ + +
+ ); +} diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts new file mode 100644 index 000000000..d8d6e2dc0 --- /dev/null +++ b/components/Onboarding/useOnboarding.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useUserProvider } from "@/providers/UserProvder"; + +export type OnboardingStep = + | "role" // Step 1: Who are you? (role picker) + | "context" // Step 2: Quick context (name, company, vibe) + | "artists" // Step 3: Add your priority artists + | "researching" // Step 4: AI deep-research in progress (aha moment loading) + | "complete"; // Step 5: Welcome dashboard reveal + +export interface OnboardingData { + roleType: string; + companyName: string; + name: string; + artists: { name: string; spotifyUrl?: string }[]; +} + +/** + * Drives the onboarding wizard state. + * Shows the flow when a new user hasn't completed onboarding yet + * (detected via onboarding_status on account_info). + */ +export function useOnboarding() { + const { userData } = useUserProvider(); + const [isOpen, setIsOpen] = useState(false); + const [step, setStep] = useState("role"); + const [data, setData] = useState>({}); + + // Show onboarding for new users (no onboarding_status set) + useEffect(() => { + if (!userData) return; + const status = userData.onboarding_status as Record | null; + if (!status || !status.completed) { + setIsOpen(true); + } + }, [userData]); + + const updateData = useCallback((patch: Partial) => { + setData(prev => ({ ...prev, ...patch })); + }, []); + + const nextStep = useCallback(() => { + setStep(prev => { + const order: OnboardingStep[] = [ + "role", + "context", + "artists", + "researching", + "complete", + ]; + const idx = order.indexOf(prev); + return order[idx + 1] ?? "complete"; + }); + }, []); + + const complete = useCallback(async () => { + setIsOpen(false); + }, []); + + return { isOpen, step, data, updateData, nextStep, complete }; +} From a11b4c785bdf6f86543867ff522c3d2d97f3c846 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 10:49:36 +0000 Subject: [PATCH 2/7] agent: address feedback --- .../Onboarding/OnboardingCompleteStep.tsx | 93 +++++---- .../Onboarding/OnboardingConnectionsStep.tsx | 136 +++++++++++++ components/Onboarding/OnboardingModal.tsx | 185 +++++++++++------- components/Onboarding/OnboardingPulseStep.tsx | 83 ++++++++ components/Onboarding/OnboardingTasksStep.tsx | 117 +++++++++++ .../Onboarding/OnboardingWelcomeStep.tsx | 95 +++++++++ components/Onboarding/useOnboarding.ts | 62 +++--- 7 files changed, 643 insertions(+), 128 deletions(-) create mode 100644 components/Onboarding/OnboardingConnectionsStep.tsx create mode 100644 components/Onboarding/OnboardingPulseStep.tsx create mode 100644 components/Onboarding/OnboardingTasksStep.tsx create mode 100644 components/Onboarding/OnboardingWelcomeStep.tsx diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx index 1776cc1ab..bc162f3b9 100644 --- a/components/Onboarding/OnboardingCompleteStep.tsx +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -4,72 +4,97 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -const HIGHLIGHTS = [ - { icon: "🎯", title: "Fan segments mapped", desc: "Know exactly who's listening and where to grow." }, - { icon: "📅", title: "Release window insights", desc: "Best timing to drop based on your fans' activity." }, - { icon: "✅", title: "Proactive tasks queued", desc: "Your first week's to-do list — already written." }, - { icon: "💬", title: "AI artist chat ready", desc: "Ask anything about your artists. Get instant answers." }, -]; - interface Props { artistNames: string[]; name: string | undefined; + connectedCount: number; + pulseEnabled: boolean; onComplete: () => void; } -export function OnboardingCompleteStep({ artistNames, name, onComplete }: Props) { +/** + * The "aha moment" reveal — everything they just set up, summarized as social proof. + */ +export function OnboardingCompleteStep({ + artistNames, + name, + connectedCount, + pulseEnabled, + onComplete, +}: Props) { const [visible, setVisible] = useState(false); useEffect(() => { - const t = setTimeout(() => setVisible(true), 100); + const t = setTimeout(() => setVisible(true), 150); 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 — your first briefing arrives tomorrow", + }, + { + icon: "✅", + text: "First week of tasks queued and ready", + }, + { + icon: "🧠", + text: "AI is learning your artists, fans, and priorities right now", + }, + ].filter(Boolean) as { icon: string; text: string }[]; + return (
{/* Celebration */} -
-
🎉
-

- {name ? `${name}, you're all set.` : "You're all set."} +
+
🚀
+

+ {name ? `${name}, you're already ahead.` : "You're already ahead."}

-

- {artistNames.length > 0 - ? `Your intelligence files for ${artistNames.slice(0, 2).join(" and ")}${artistNames.length > 2 ? ` (+${artistNames.length - 2} more)` : ""} are ready.` - : "Your Recoupable workspace is ready to go."} +

+ While your competitors are guessing, you have AI running intelligence on every move.

- {/* Feature cards */} -
- {HIGHLIGHTS.map((h, i) => ( + {/* Summary pills */} +
+ {summaryItems.map((item, i) => (
- {h.icon} -

{h.title}

-

{h.desc}

+ {item.icon} + {item.text}
))}
- - -

- Your friends will ask how you got so ahead. Tell them. -

+
+ +

+ Your friends in music will want to know what you're using. You don't have to tell them. +

+
); } diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx new file mode 100644 index 000000000..78573e175 --- /dev/null +++ b/components/Onboarding/OnboardingConnectionsStep.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, ExternalLink } from "lucide-react"; +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; +} + +/** + * Step to connect key platform integrations during onboarding. + */ +export function OnboardingConnectionsStep({ connected, onConnect, onNext }: 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 ? ( + + ) : ( + + )} +
+ ); + })} +
+ +
+ + {connected.length === 0 && ( +

+ You can always connect more later in Settings → Connectors +

+ )} +
+
+ ); +} diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 5ddfa49f9..ccaad9a1f 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -1,60 +1,63 @@ "use client"; import { useCallback, useEffect } from "react"; -import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useOnboarding } from "./useOnboarding"; +import { OnboardingWelcomeStep } from "./OnboardingWelcomeStep"; import { OnboardingRoleStep } from "./OnboardingRoleStep"; import { OnboardingContextStep } from "./OnboardingContextStep"; import { OnboardingArtistsStep } from "./OnboardingArtistsStep"; -import { OnboardingResearchingStep } from "./OnboardingResearchingStep"; +import { OnboardingConnectionsStep } from "./OnboardingConnectionsStep"; +import { OnboardingPulseStep } from "./OnboardingPulseStep"; +import { OnboardingTasksStep } from "./OnboardingTasksStep"; import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; import { useUserProvider } from "@/providers/UserProvder"; import { useArtistProvider } from "@/providers/ArtistProvider"; import saveArtist from "@/lib/saveArtist"; +import { updatePulse } from "@/lib/pulse/updatePulse"; +import { useAccessToken } from "@/hooks/useAccessToken"; + +type Step = + | "welcome" + | "role" + | "context" + | "artists" + | "connections" + | "pulse" + | "tasks" + | "complete"; + +// Steps that show the progress bar +const PROGRESS_STEPS: Step[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; + +function getProgress(step: Step): number { + const idx = PROGRESS_STEPS.indexOf(step); + if (idx === -1) return 0; + return Math.round(((idx + 1) / PROGRESS_STEPS.length) * 100); +} -const STEP_TITLES: Record = { - role: "1 of 3", - context: "2 of 3", - artists: "3 of 3", - researching: "", - complete: "", -}; +function getStepLabel(step: Step): string { + const idx = PROGRESS_STEPS.indexOf(step); + if (idx === -1) return ""; + return `${idx + 1} of ${PROGRESS_STEPS.length}`; +} /** - * Full-screen onboarding wizard that fires once for new users. - * Walks through role selection → context → artist setup → research → wow moment. + * Full onboarding wizard — non-dismissable modal that fires once for new users. + * Sequence: welcome → role → context → artists → connections → pulse → tasks → complete */ export default function OnboardingModal() { const { isOpen, step, data, updateData, nextStep, complete } = useOnboarding(); const { userData } = useUserProvider(); const { getArtists } = useArtistProvider(); + const accessToken = useAccessToken(); - // Persist role + context to account when we reach the artists step - useEffect(() => { - if (step !== "artists" || !userData?.account_id) return; - if (!data.roleType && !data.name) return; - - 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, - }), - }).catch(console.error); - }, [step]); // eslint-disable-line react-hooks/exhaustive-deps - - // When researching starts, actually create artists and persist onboarding_status + // When we hit tasks step: create artists, activate pulse, persist onboarding useEffect(() => { - if (step !== "researching" || !userData?.account_id) return; + if (step !== "tasks" || !userData?.account_id) return; const run = async () => { - // Create each artist in parallel + // Create priority artists const artistPromises = (data.artists ?? []).map(a => saveArtist({ name: a.name, @@ -64,18 +67,35 @@ export default function OnboardingModal() { ); await Promise.allSettled(artistPromises); - // Mark onboarding complete on account_info + // Activate pulse if user opted in + if (data.pulseEnabled && accessToken) { + await updatePulse({ accessToken, active: true }).catch(console.error); + } + + // Persist role + context + onboarding status await fetch("/api/account/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accountId: userData.account_id, - onboardingStatus: { completed: true, completedAt: new Date().toISOString() }, + 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 artists list in sidebar + // Refresh the artist sidebar if (typeof getArtists === "function") { await getArtists().catch(console.error); } @@ -86,16 +106,14 @@ export default function OnboardingModal() { const handleComplete = useCallback(async () => { await complete(); - // Kick off a proactive first chat after a short delay - setTimeout(() => { - const artistNames = (data.artists ?? []).map(a => a.name); - if (artistNames.length > 0) { - const q = encodeURIComponent( - `Give me a quick status report and top 3 priorities for ${artistNames[0]} this week`, - ); - window.location.href = `/?q=${q}`; - } - }, 300); + // Auto-fire a proactive first chat + 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]); return ( @@ -105,31 +123,35 @@ export default function OnboardingModal() { onInteractOutside={e => e.preventDefault()} onEscapeKeyDown={e => e.preventDefault()} > - {/* Header bar */} - {STEP_TITLES[step] && ( -
-
- Welcome to Recoupable + {/* Header */} + {PROGRESS_STEPS.includes(step as Step) && ( + <> +
+
+ Recoupable +
+ {getStepLabel(step as Step)}
- {STEP_TITLES[step]} -
- )} - - {/* Progress bar */} - {["role", "context", "artists"].includes(step) && ( -
-
-
+ {/* Progress bar */} +
+
+
+ )} {/* Step content */} -
+
+ {step === "welcome" && ( + + )} + {step === "role" && ( )} - {step === "researching" && ( - + updateData({ connectedSlugs: [...(data.connectedSlugs ?? []), slug] }) + } + onNext={nextStep} + /> + )} + + {step === "pulse" && ( + updateData({ pulseEnabled: v })} + onNext={nextStep} + /> + )} + + {step === "tasks" && ( + a.name)} - onComplete={nextStep} + onNext={nextStep} /> )} @@ -167,6 +208,8 @@ export default function OnboardingModal() { a.name)} name={data.name} + connectedCount={(data.connectedSlugs ?? []).length} + pulseEnabled={data.pulseEnabled ?? false} onComplete={handleComplete} /> )} diff --git a/components/Onboarding/OnboardingPulseStep.tsx b/components/Onboarding/OnboardingPulseStep.tsx new file mode 100644 index 000000000..e05a9026c --- /dev/null +++ b/components/Onboarding/OnboardingPulseStep.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; + +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; +} + +/** + * Onboarding step to activate Pulse — daily AI briefings per artist. + */ +export function OnboardingPulseStep({ enabled, onToggle, onNext }: 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/OnboardingTasksStep.tsx b/components/Onboarding/OnboardingTasksStep.tsx new file mode 100644 index 000000000..ccfafd1a4 --- /dev/null +++ b/components/Onboarding/OnboardingTasksStep.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +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; +} + +/** + * 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 }: 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..d7540f748 --- /dev/null +++ b/components/Onboarding/OnboardingWelcomeStep.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useUserProvider } from "@/providers/UserProvder"; + +const RESEARCH_LINES = [ + "Mapping your industry footprint…", + "Scanning music business context…", + "Preparing your personalized workspace…", + "Queueing your first artist research tasks…", + "Almost ready to blow your mind…", +]; + +interface Props { + onDone: () => void; +} + +/** + * Animated "we're researching you" welcome screen. + * Plays for ~2.5s then advances automatically. + */ +export function OnboardingWelcomeStep({ onDone }: Props) { + const { email, userData } = useUserProvider(); + const [lineIdx, setLineIdx] = useState(0); + const [done, setDone] = useState(false); + + const displayName = + userData?.name || (email ? email.split("@")[0] : null); + + useEffect(() => { + const interval = setInterval(() => { + setLineIdx(prev => { + const next = prev + 1; + if (next >= RESEARCH_LINES.length) { + clearInterval(interval); + setDone(true); + setTimeout(onDone, 600); + return prev; + } + return next; + }); + }, 500); + return () => clearInterval(interval); + }, [onDone]); + + return ( +
+ {/* Pulsing orb */} +
+
+
+
+ 🎵 +
+
+ +
+

+ {displayName + ? `Hey ${displayName} — welcome to Recoupable.` + : "Welcome to Recoupable."} +

+

+ The AI platform built for people who actually move music. +

+
+ +
+ {RESEARCH_LINES.map((line, i) => ( +
+ {i < lineIdx ? "✅" : i === lineIdx ? "⏳" : "○"} + {line} +
+ ))} +
+ + {done && ( +

+ Let's go → +

+ )} +
+ ); +} diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts index d8d6e2dc0..d0712b572 100644 --- a/components/Onboarding/useOnboarding.ts +++ b/components/Onboarding/useOnboarding.ts @@ -4,35 +4,58 @@ import { useCallback, useEffect, useState } from "react"; import { useUserProvider } from "@/providers/UserProvder"; export type OnboardingStep = - | "role" // Step 1: Who are you? (role picker) - | "context" // Step 2: Quick context (name, company, vibe) - | "artists" // Step 3: Add your priority artists - | "researching" // Step 4: AI deep-research in progress (aha moment loading) - | "complete"; // Step 5: Welcome dashboard reveal + | "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: { name: string; spotifyUrl?: string }[]; + artists: OnboardingArtist[]; + connectedSlugs: string[]; + pulseEnabled: boolean; } +const STEP_ORDER: OnboardingStep[] = [ + "welcome", + "role", + "context", + "artists", + "connections", + "pulse", + "tasks", + "complete", +]; + /** - * Drives the onboarding wizard state. - * Shows the flow when a new user hasn't completed onboarding yet - * (detected via onboarding_status on account_info). + * 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("role"); - const [data, setData] = useState>({}); + const [step, setStep] = useState("welcome"); + const [data, setData] = useState>({ + artists: [], + connectedSlugs: [], + pulseEnabled: false, + }); - // Show onboarding for new users (no onboarding_status set) useEffect(() => { if (!userData) return; const status = userData.onboarding_status as Record | null; - if (!status || !status.completed) { + if (!status?.completed) { setIsOpen(true); } }, [userData]); @@ -43,19 +66,12 @@ export function useOnboarding() { const nextStep = useCallback(() => { setStep(prev => { - const order: OnboardingStep[] = [ - "role", - "context", - "artists", - "researching", - "complete", - ]; - const idx = order.indexOf(prev); - return order[idx + 1] ?? "complete"; + const idx = STEP_ORDER.indexOf(prev); + return STEP_ORDER[idx + 1] ?? "complete"; }); }, []); - const complete = useCallback(async () => { + const complete = useCallback(() => { setIsOpen(false); }, []); From 50e81ca23facf61ad9a83b44eb161cdd3acf5b6f Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 11:10:10 +0000 Subject: [PATCH 3/7] agent: address feedback --- .../Onboarding/OnboardingArtistsStep.tsx | 286 ++++++++++++------ .../Onboarding/OnboardingCompleteStep.tsx | 160 ++++++---- .../Onboarding/OnboardingConnectionsStep.tsx | 20 +- .../Onboarding/OnboardingContextStep.tsx | 58 +++- components/Onboarding/OnboardingModal.tsx | 68 ++--- components/Onboarding/OnboardingPulseStep.tsx | 20 +- components/Onboarding/OnboardingRoleStep.tsx | 33 +- components/Onboarding/OnboardingStepDots.tsx | 67 ++++ components/Onboarding/OnboardingTasksStep.tsx | 10 +- .../Onboarding/OnboardingWelcomeStep.tsx | 118 +++++--- components/Onboarding/useOnboarding.ts | 10 +- 11 files changed, 591 insertions(+), 259 deletions(-) create mode 100644 components/Onboarding/OnboardingStepDots.tsx diff --git a/components/Onboarding/OnboardingArtistsStep.tsx b/components/Onboarding/OnboardingArtistsStep.tsx index 20476df50..8add34240 100644 --- a/components/Onboarding/OnboardingArtistsStep.tsx +++ b/components/Onboarding/OnboardingArtistsStep.tsx @@ -1,78 +1,222 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Plus, Music2 } from "lucide-react"; +import { X, Music2, Search, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { NEW_API_BASE_URL } from "@/lib/consts"; +import { useAccessToken } from "@/hooks/useAccessToken"; interface ArtistEntry { name: string; spotifyUrl?: string; + imageUrl?: string; +} + +interface SpotifyArtist { + id: string; + name: string; + external_urls: { spotify: string }; + images: { url: string }[]; + followers: { total: number }; } interface Props { artists: ArtistEntry[]; onUpdate: (artists: ArtistEntry[]) => void; onNext: () => void; + onBack: () => void; + roleType?: string; } -export function OnboardingArtistsStep({ artists, onUpdate, onNext }: Props) { - const [newName, setNewName] = useState(""); - const [newUrl, setNewUrl] = useState(""); - const [showUrlField, setShowUrlField] = useState(false); - - const addArtist = () => { - if (!newName.trim()) return; - onUpdate([...artists, { name: newName.trim(), spotifyUrl: newUrl.trim() || undefined }]); - setNewName(""); - setNewUrl(""); - setShowUrlField(false); - }; +const ROLE_PLACEHOLDER: Record = { + artist_manager: "Search for an artist you manage…", + label: "Search for a roster artist…", + artist: "Search for yourself or a collaborator…", + publisher: "Search for a catalog artist…", + other: "Search for an artist…", +}; + +/** + * Artist step with live Spotify search, avatar display, and manual fallback. + */ +export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleType }: Props) { + const accessToken = useAccessToken(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [focused, setFocused] = 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 removeArtist = (idx: number) => { - onUpdate(artists.filter((_, i) => i !== idx)); + 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, + }, + ]); + setQuery(""); + setResults([]); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") addArtist(); + const addManual = () => { + const trimmed = query.trim(); + if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return; + onUpdate([...artists, { name: trimmed }]); + setQuery(""); + setResults([]); }; + const remove = (idx: number) => onUpdate(artists.filter((_, i) => i !== idx)); + + const placeholder = ROLE_PLACEHOLDER[roleType ?? ""] ?? "Search for an artist…"; + 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 -

+

Add your priority artists

- We'll run deep research on each one — fan segments, release data, competitive - analysis, and proactive tasks — before you ever open a chat. + We'll run deep research — fan data, release windows, competitive benchmarks + — before you ever open a chat.

- {/* Artist list */} + {/* 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 results */} + {showDropdown && ( +
+ {results.map(a => ( + + ))} +
+ )} + + {/* Manual add fallback */} + {showManualAdd && ( +
+ +
+ )} +
+ + {/* Added artists */} {artists.length > 0 && (
    {artists.map((a, i) => (
  • -
    - -
    -

    {a.name}

    - {a.spotifyUrl && ( -

    - {a.spotifyUrl} -

    - )} + {a.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {a.name} + ) : ( +
    +
    + )} +
    +

    {a.name}

    + {a.spotifyUrl && ( +

    Spotify connected ✓

    + )}
    @@ -81,64 +225,30 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext }: Props) {
)} - {/* Add artist input */} -
-
- setNewName(e.target.value)} - onKeyDown={handleKeyDown} - className="flex-1" - /> - -
- - - - {showUrlField && ( - setNewUrl(e.target.value)} - onKeyDown={handleKeyDown} - className="text-sm" - /> - )} -
- -
+
+ - {artists.length === 0 && ( - - )}
+ + {artists.length === 0 && ( + + )}
); } diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx index bc162f3b9..0b9e27176 100644 --- a/components/Onboarding/OnboardingCompleteStep.tsx +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; interface Props { artistNames: string[]; @@ -12,8 +12,43 @@ interface Props { onComplete: () => void; } +function useConfetti() { + const triggered = useRef(false); + + useEffect(() => { + if (triggered.current) return; + triggered.current = true; + + // Dynamically import canvas-confetti if available, otherwise use CSS fallback + import("canvas-confetti").then(({ default: confetti }) => { + confetti({ + particleCount: 120, + spread: 80, + origin: { y: 0.55 }, + colors: ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f97316"], + }); + setTimeout(() => { + confetti({ + particleCount: 60, + spread: 100, + origin: { y: 0.5, x: 0.2 }, + angle: 60, + }); + confetti({ + particleCount: 60, + spread: 100, + origin: { y: 0.5, x: 0.8 }, + angle: 120, + }); + }, 300); + }).catch(() => { + // canvas-confetti not available — silently skip + }); + }, []); +} + /** - * The "aha moment" reveal — everything they just set up, summarized as social proof. + * The "aha moment" — everything summarized with Framer Motion reveals + confetti. */ export function OnboardingCompleteStep({ artistNames, @@ -22,17 +57,20 @@ export function OnboardingCompleteStep({ pulseEnabled, onComplete, }: Props) { + useConfetti(); const [visible, setVisible] = useState(false); useEffect(() => { - const t = setTimeout(() => setVisible(true), 150); + 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` : ""}`, + text: `Deep research running on ${artistNames.slice(0, 2).join(" & ")}${ + artistNames.length > 2 ? ` +${artistNames.length - 2} more` : "" + }`, }, connectedCount > 0 && { icon: "🔗", @@ -40,61 +78,77 @@ export function OnboardingCompleteStep({ }, pulseEnabled && { icon: "⚡", - text: "Pulse active — your first briefing arrives tomorrow", - }, - { - icon: "✅", - text: "First week of tasks queued and ready", - }, - { - icon: "🧠", - text: "AI is learning your artists, fans, and priorities right now", + 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 ( -
- {/* Celebration */} -
-
🚀
-

- {name ? `${name}, you're already ahead.` : "You're already ahead."} -

-

- While your competitors are guessing, you have AI running intelligence on every move. -

-
+ + {visible && ( + + {/* Trophy */} + + 🚀 + - {/* Summary pills */} -
- {summaryItems.map((item, i) => ( -
- {item.icon} - {item.text} +

+ {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 friends in music will want to know what you're using. You don't have to tell them. -

-
-
+ + +

+ Your peers in music will want to know what you're using. + You don't have to tell them. +

+
+
+ )} +
); } diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx index 78573e175..78ad072ee 100644 --- a/components/Onboarding/OnboardingConnectionsStep.tsx +++ b/components/Onboarding/OnboardingConnectionsStep.tsx @@ -38,12 +38,13 @@ 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 }: Props) { +export function OnboardingConnectionsStep({ connected, onConnect, onNext, onBack }: Props) { const accessToken = useAccessToken(); const [connecting, setConnecting] = useState(null); @@ -122,14 +123,15 @@ export function OnboardingConnectionsStep({ connected, onConnect, onNext }: Prop
- - {connected.length === 0 && ( -

- You can always connect more later in Settings → Connectors -

- )} +
+ + +
+

+ More connectors available in Settings → Connectors +

); diff --git a/components/Onboarding/OnboardingContextStep.tsx b/components/Onboarding/OnboardingContextStep.tsx index 35a4d5b68..c03f5d999 100644 --- a/components/Onboarding/OnboardingContextStep.tsx +++ b/components/Onboarding/OnboardingContextStep.tsx @@ -1,32 +1,67 @@ "use client"; +import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useUserProvider } from "@/providers/UserProvder"; interface Props { name: string | undefined; companyName: string | undefined; + roleType: string | undefined; onChangeName: (v: string) => void; onChangeCompany: (v: string) => void; onNext: () => void; + onBack: () => void; } +const ROLE_COMPANY_LABEL: Record = { + artist_manager: "Management company", + label: "Label name", + artist: "Your artist name or team", + publisher: "Publishing company", + dsp: "Company / Platform", + other: "Company or organization", +}; + +/** + * 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 = ROLE_COMPANY_LABEL[roleType ?? ""] ?? "Company"; + return (
-

- Let's get to know you -

+

Quick intro

- Just the basics — we'll do the heavy lifting. + We'll use this to personalize your workspace.

@@ -38,23 +73,30 @@ export function OnboardingContextStep({ placeholder="e.g. Jordan Lee" value={name ?? ""} onChange={e => onChangeName(e.target.value)} + onKeyDown={e => e.key === "Enter" && name?.trim() && onNext()} />
- + onChangeCompany(e.target.value)} + onKeyDown={e => e.key === "Enter" && name?.trim() && onNext()} />
- +
+ + +
); } diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index ccaad9a1f..981d716ad 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -11,6 +11,7 @@ import { OnboardingConnectionsStep } from "./OnboardingConnectionsStep"; import { OnboardingPulseStep } from "./OnboardingPulseStep"; import { OnboardingTasksStep } from "./OnboardingTasksStep"; import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; +import { OnboardingStepDots } from "./OnboardingStepDots"; import { useUserProvider } from "@/providers/UserProvder"; import { useArtistProvider } from "@/providers/ArtistProvider"; import saveArtist from "@/lib/saveArtist"; @@ -27,37 +28,31 @@ type Step = | "tasks" | "complete"; -// Steps that show the progress bar const PROGRESS_STEPS: Step[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; -function getProgress(step: Step): number { - const idx = PROGRESS_STEPS.indexOf(step); - if (idx === -1) return 0; - return Math.round(((idx + 1) / PROGRESS_STEPS.length) * 100); -} - -function getStepLabel(step: Step): string { - const idx = PROGRESS_STEPS.indexOf(step); - if (idx === -1) return ""; - return `${idx + 1} of ${PROGRESS_STEPS.length}`; -} - /** * Full onboarding wizard — non-dismissable modal that fires once for new users. - * Sequence: welcome → role → context → artists → connections → pulse → tasks → complete + * Flow: welcome → role → context → artists → connections → pulse → tasks → complete + * + * Features: + * - Back navigation on all steps after welcome + * - Spotify artist search with avatars + * - Pre-filled name from Privy + * - Confetti on complete + * - Framer Motion transitions + * - Pulse & connectors activated inline */ export default function OnboardingModal() { - const { isOpen, step, data, updateData, nextStep, complete } = useOnboarding(); + const { isOpen, step, data, updateData, nextStep, prevStep, complete } = useOnboarding(); const { userData } = useUserProvider(); const { getArtists } = useArtistProvider(); const accessToken = useAccessToken(); - // When we hit tasks step: create artists, activate pulse, persist onboarding + // When tasks step is reached: persist everything and set up artists + pulse useEffect(() => { if (step !== "tasks" || !userData?.account_id) return; const run = async () => { - // Create priority artists const artistPromises = (data.artists ?? []).map(a => saveArtist({ name: a.name, @@ -67,12 +62,10 @@ export default function OnboardingModal() { ); await Promise.allSettled(artistPromises); - // Activate pulse if user opted in if (data.pulseEnabled && accessToken) { await updatePulse({ accessToken, active: true }).catch(console.error); } - // Persist role + context + onboarding status await fetch("/api/account/update", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -95,7 +88,6 @@ export default function OnboardingModal() { }), }).catch(console.error); - // Refresh the artist sidebar if (typeof getArtists === "function") { await getArtists().catch(console.error); } @@ -106,7 +98,6 @@ export default function OnboardingModal() { const handleComplete = useCallback(async () => { await complete(); - // Auto-fire a proactive first chat const artistNames = (data.artists ?? []).map(a => a.name); if (artistNames.length > 0) { const q = encodeURIComponent( @@ -116,6 +107,8 @@ export default function OnboardingModal() { } }, [complete, data.artists]); + const isProgressStep = PROGRESS_STEPS.includes(step as Step); + return ( {}}> e.preventDefault()} onEscapeKeyDown={e => e.preventDefault()} > - {/* Header */} - {PROGRESS_STEPS.includes(step as Step) && ( - <> -
-
- Recoupable -
- {getStepLabel(step as Step)} -
- {/* Progress bar */} -
-
+ {/* Header + step dots */} + {isProgressStep && ( +
+
+ Recoupable + + {PROGRESS_STEPS.indexOf(step as Step) + 1} of {PROGRESS_STEPS.length} +
- + +
)} {/* Step content */} @@ -164,9 +151,11 @@ export default function OnboardingModal() { updateData({ name: v })} onChangeCompany={v => updateData({ companyName: v })} onNext={nextStep} + onBack={prevStep} /> )} @@ -175,6 +164,8 @@ export default function OnboardingModal() { artists={data.artists ?? []} onUpdate={artists => updateData({ artists })} onNext={nextStep} + onBack={prevStep} + roleType={data.roleType} /> )} @@ -185,6 +176,7 @@ export default function OnboardingModal() { updateData({ connectedSlugs: [...(data.connectedSlugs ?? []), slug] }) } onNext={nextStep} + onBack={prevStep} /> )} @@ -193,6 +185,7 @@ export default function OnboardingModal() { enabled={data.pulseEnabled ?? false} onToggle={v => updateData({ pulseEnabled: v })} onNext={nextStep} + onBack={prevStep} /> )} @@ -201,6 +194,7 @@ export default function OnboardingModal() { roleType={data.roleType} artistNames={(data.artists ?? []).map(a => a.name)} onNext={nextStep} + onBack={prevStep} /> )} diff --git a/components/Onboarding/OnboardingPulseStep.tsx b/components/Onboarding/OnboardingPulseStep.tsx index e05a9026c..cc9c97977 100644 --- a/components/Onboarding/OnboardingPulseStep.tsx +++ b/components/Onboarding/OnboardingPulseStep.tsx @@ -15,12 +15,13 @@ 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 }: Props) { +export function OnboardingPulseStep({ enabled, onToggle, onNext, onBack }: Props) { return (
@@ -71,13 +72,16 @@ export function OnboardingPulseStep({ enabled, onToggle, onNext }: Props) { ))}
- +
+ + +
); } diff --git a/components/Onboarding/OnboardingRoleStep.tsx b/components/Onboarding/OnboardingRoleStep.tsx index dc530935a..fb5ac67a0 100644 --- a/components/Onboarding/OnboardingRoleStep.tsx +++ b/components/Onboarding/OnboardingRoleStep.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { motion } from "motion/react"; const ROLES = [ { id: "artist_manager", label: "Artist Manager", icon: "🎯", description: "I manage one or more artists" }, @@ -21,23 +22,27 @@ interface Props { export function OnboardingRoleStep({ selected, onSelect, onNext }: Props) { return (
-
-

- What best describes you? -

+ +

What best describes you?

- We'll personalize everything to your world. + We'll personalize your entire workspace to your world.

-
+
- {ROLES.map(role => ( - + {role.description} + ))}
-
diff --git a/components/Onboarding/OnboardingStepDots.tsx b/components/Onboarding/OnboardingStepDots.tsx new file mode 100644 index 000000000..fbb46f7b9 --- /dev/null +++ b/components/Onboarding/OnboardingStepDots.tsx @@ -0,0 +1,67 @@ +import { cn } from "@/lib/utils"; + +type OnboardingStep = + | "welcome" + | "role" + | "context" + | "artists" + | "connections" + | "pulse" + | "tasks" + | "complete"; + +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 index ccfafd1a4..c6e9a84bd 100644 --- a/components/Onboarding/OnboardingTasksStep.tsx +++ b/components/Onboarding/OnboardingTasksStep.tsx @@ -51,13 +51,14 @@ 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 }: Props) { +export function OnboardingTasksStep({ roleType, artistNames, onNext, onBack }: Props) { const tasks = ROLE_TASKS[roleType ?? ""] ?? DEFAULT_TASKS; const [revealed, setRevealed] = useState([]); @@ -109,9 +110,10 @@ export function OnboardingTasksStep({ roleType, artistNames, onNext }: Props) { ))}
- +
+ + +
); } diff --git a/components/Onboarding/OnboardingWelcomeStep.tsx b/components/Onboarding/OnboardingWelcomeStep.tsx index d7540f748..4136a98af 100644 --- a/components/Onboarding/OnboardingWelcomeStep.tsx +++ b/components/Onboarding/OnboardingWelcomeStep.tsx @@ -1,15 +1,21 @@ "use client"; import { useEffect, useState } from "react"; -import { cn } from "@/lib/utils"; +import { motion, AnimatePresence } from "motion/react"; import { useUserProvider } from "@/providers/UserProvder"; const RESEARCH_LINES = [ - "Mapping your industry footprint…", + "Identifying your industry footprint…", "Scanning music business context…", "Preparing your personalized workspace…", - "Queueing your first artist research tasks…", - "Almost ready to blow your mind…", + "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 { @@ -17,16 +23,22 @@ interface Props { } /** - * Animated "we're researching you" welcome screen. - * Plays for ~2.5s then advances automatically. + * 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); - const displayName = - userData?.name || (email ? email.split("@")[0] : null); + // 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(() => { @@ -35,60 +47,96 @@ export function OnboardingWelcomeStep({ onDone }: Props) { if (next >= RESEARCH_LINES.length) { clearInterval(interval); setDone(true); - setTimeout(onDone, 600); + setTimeout(onDone, 700); return prev; } return next; }); - }, 500); + }, 520); return () => clearInterval(interval); }, [onDone]); return ( -
- {/* Pulsing orb */} -
+
+ {/* Animated orb */} +
-
+ 🎵 -
+
-
+ {/* Headline */} +

{displayName ? `Hey ${displayName} — welcome to Recoupable.` : "Welcome to Recoupable."}

-

+

The AI platform built for people who actually move music.

-
+ -
- {RESEARCH_LINES.map((line, i) => ( -
+ {SOCIAL_PROOF.map((s, i) => ( + - {i < lineIdx ? "✅" : i === lineIdx ? "⏳" : "○"} - {line} -
+ {s} + ))} + + + {/* Animated research lines */} +
+ + {RESEARCH_LINES.map((line, i) => + i <= lineIdx ? ( + + + {i < lineIdx ? "✅" : "⏳"} + + + {line} + + + ) : null, + )} +
{done && ( -

- Let's go → -

+ + Let's set you up → + )}
); diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts index d0712b572..6a2e763b8 100644 --- a/components/Onboarding/useOnboarding.ts +++ b/components/Onboarding/useOnboarding.ts @@ -71,9 +71,17 @@ export function useOnboarding() { }); }, []); + 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, complete }; + return { isOpen, step, data, updateData, nextStep, prevStep, complete }; } From 1df4f3db1af5daa8467370a5cb59f0b3e4f2a6b8 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 12:02:54 +0000 Subject: [PATCH 4/7] agent: address feedback --- .../Onboarding/OnboardingArtistsStep.tsx | 200 +++++++----------- .../Onboarding/OnboardingConnectionsStep.tsx | 12 +- .../Onboarding/OnboardingContextStep.tsx | 26 +-- components/Onboarding/OnboardingModal.tsx | 83 +------- .../Onboarding/OnboardingNavButtons.tsx | 29 +++ components/Onboarding/OnboardingPulseStep.tsx | 16 +- components/Onboarding/OnboardingRoleStep.tsx | 12 +- components/Onboarding/OnboardingTasksStep.tsx | 10 +- components/Onboarding/onboardingRoleConfig.ts | 81 +++++++ components/Onboarding/useOnboardingPersist.ts | 74 +++++++ .../Onboarding/useSpotifyArtistSearch.ts | 66 ++++++ 11 files changed, 362 insertions(+), 247 deletions(-) create mode 100644 components/Onboarding/OnboardingNavButtons.tsx create mode 100644 components/Onboarding/onboardingRoleConfig.ts create mode 100644 components/Onboarding/useOnboardingPersist.ts create mode 100644 components/Onboarding/useSpotifyArtistSearch.ts diff --git a/components/Onboarding/OnboardingArtistsStep.tsx b/components/Onboarding/OnboardingArtistsStep.tsx index 8add34240..2baeea9d4 100644 --- a/components/Onboarding/OnboardingArtistsStep.tsx +++ b/components/Onboarding/OnboardingArtistsStep.tsx @@ -1,27 +1,20 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Music2, Search, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; -import { NEW_API_BASE_URL } from "@/lib/consts"; -import { useAccessToken } from "@/hooks/useAccessToken"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; +import { getRoleConfig } from "./onboardingRoleConfig"; +import { useSpotifyArtistSearch, type SpotifyArtist } from "./useSpotifyArtistSearch"; +import { useState } from "react"; -interface ArtistEntry { +export interface ArtistEntry { name: string; spotifyUrl?: string; imageUrl?: string; } -interface SpotifyArtist { - id: string; - name: string; - external_urls: { spotify: string }; - images: { url: string }[]; - followers: { total: number }; -} - interface Props { artists: ArtistEntry[]; onUpdate: (artists: ArtistEntry[]) => void; @@ -30,50 +23,14 @@ interface Props { roleType?: string; } -const ROLE_PLACEHOLDER: Record = { - artist_manager: "Search for an artist you manage…", - label: "Search for a roster artist…", - artist: "Search for yourself or a collaborator…", - publisher: "Search for a catalog artist…", - other: "Search for an artist…", -}; - /** - * Artist step with live Spotify search, avatar display, and manual fallback. + * Artist step — live Spotify search with avatars, manual fallback. */ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleType }: Props) { - const accessToken = useAccessToken(); - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [searching, setSearching] = useState(false); + const { query, setQuery, results, searching, clearResults } = useSpotifyArtistSearch(); const [focused, setFocused] = 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 { artistPlaceholder } = getRoleConfig(roleType); const addFromSpotify = (a: SpotifyArtist) => { if (artists.some(x => x.spotifyUrl === a.external_urls.spotify)) return; @@ -85,21 +42,18 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleT imageUrl: a.images?.[0]?.url, }, ]); - setQuery(""); - setResults([]); + clearResults(); }; const addManual = () => { const trimmed = query.trim(); if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return; onUpdate([...artists, { name: trimmed }]); - setQuery(""); - setResults([]); + clearResults(); }; const remove = (idx: number) => onUpdate(artists.filter((_, i) => i !== idx)); - const placeholder = ROLE_PLACEHOLDER[roleType ?? ""] ?? "Search for an artist…"; const showDropdown = focused && (results.length > 0 || (searching && query.length > 1)); const showManualAdd = focused && query.trim().length > 1 && !searching && results.length === 0; @@ -118,7 +72,7 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleT
setQuery(e.target.value)} onFocus={() => setFocused(true)} @@ -134,35 +88,11 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleT )}
- {/* Dropdown results */} + {/* Dropdown */} {showDropdown && (
{results.map(a => ( - + ))}
)} @@ -175,9 +105,7 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleT onMouseDown={addManual} className="flex items-center gap-3 w-full px-3 py-2.5 hover:bg-muted transition-colors text-left" > -
- -
+

Add “{query.trim()}”

Add manually

@@ -191,26 +119,12 @@ export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleT {artists.length > 0 && (
    {artists.map((a, i) => ( -
  • - {a.imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {a.name} - ) : ( -
    - -
    - )} +
  • +

    {a.name}

    {a.spotifyUrl && ( -

    Spotify connected ✓

    +

    Spotify connected ✓

    )}
    - +
    + 0 + ? `Research ${artists.length} artist${artists.length > 1 ? "s" : ""} →` + : "Add at least one artist" + } + /> + {artists.length === 0 && ( + + )}
    +
+ ); +} - {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 + {name + ) : ( +
+
); } + +function ArtistSearchResult({ + artist, + onSelect, +}: { + artist: SpotifyArtist; + onSelect: (a: SpotifyArtist) => void; +}) { + return ( + + ); +} diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx index 78ad072ee..31e156ac8 100644 --- a/components/Onboarding/OnboardingConnectionsStep.tsx +++ b/components/Onboarding/OnboardingConnectionsStep.tsx @@ -3,6 +3,7 @@ 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"; @@ -123,12 +124,11 @@ export function OnboardingConnectionsStep({ connected, onConnect, onNext, onBack
-
- - -
+ 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 index c03f5d999..6d0082c8c 100644 --- a/components/Onboarding/OnboardingContextStep.tsx +++ b/components/Onboarding/OnboardingContextStep.tsx @@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button"; 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; @@ -16,15 +18,6 @@ interface Props { onBack: () => void; } -const ROLE_COMPANY_LABEL: Record = { - artist_manager: "Management company", - label: "Label name", - artist: "Your artist name or team", - publisher: "Publishing company", - dsp: "Company / Platform", - other: "Company or organization", -}; - /** * Context step — pre-fills name from Privy, adapts company label to role. */ @@ -54,7 +47,7 @@ export function OnboardingContextStep({ } }, [userData?.name, email]); // eslint-disable-line react-hooks/exhaustive-deps - const companyLabel = ROLE_COMPANY_LABEL[roleType ?? ""] ?? "Company"; + const { companyLabel } = getRoleConfig(roleType); return (
@@ -89,14 +82,11 @@ export function OnboardingContextStep({
-
- - -
+
); } diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 981d716ad..0d920eb9b 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useOnboarding } from "./useOnboarding"; +import { useOnboardingPersist } from "./useOnboardingPersist"; import { OnboardingWelcomeStep } from "./OnboardingWelcomeStep"; import { OnboardingRoleStep } from "./OnboardingRoleStep"; import { OnboardingContextStep } from "./OnboardingContextStep"; @@ -12,11 +13,6 @@ import { OnboardingPulseStep } from "./OnboardingPulseStep"; import { OnboardingTasksStep } from "./OnboardingTasksStep"; import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; import { OnboardingStepDots } from "./OnboardingStepDots"; -import { useUserProvider } from "@/providers/UserProvder"; -import { useArtistProvider } from "@/providers/ArtistProvider"; -import saveArtist from "@/lib/saveArtist"; -import { updatePulse } from "@/lib/pulse/updatePulse"; -import { useAccessToken } from "@/hooks/useAccessToken"; type Step = | "welcome" @@ -31,69 +27,18 @@ type Step = const PROGRESS_STEPS: Step[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; /** - * Full onboarding wizard — non-dismissable modal that fires once for new users. - * Flow: welcome → role → context → artists → connections → pulse → tasks → complete - * - * Features: - * - Back navigation on all steps after welcome - * - Spotify artist search with avatars - * - Pre-filled name from Privy - * - Confetti on complete - * - Framer Motion transitions - * - Pulse & connectors activated inline + * 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 { userData } = useUserProvider(); - const { getArtists } = useArtistProvider(); - const accessToken = useAccessToken(); + const { persist } = useOnboardingPersist(); - // When tasks step is reached: persist everything and set up artists + pulse + // Persist everything when the tasks (penultimate) step is reached useEffect(() => { - if (step !== "tasks" || !userData?.account_id) return; - - const run = async () => { - const artistPromises = (data.artists ?? []).map(a => - saveArtist({ - name: a.name, - spotifyUrl: a.spotifyUrl, - accountId: userData.account_id, - }).catch(console.error), - ); - await Promise.allSettled(artistPromises); - - if (data.pulseEnabled && accessToken) { - await updatePulse({ accessToken, active: true }).catch(console.error); - } - - 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); - - if (typeof getArtists === "function") { - await getArtists().catch(console.error); - } - }; - - run(); + if (step === "tasks") { + persist(data); + } }, [step]); // eslint-disable-line react-hooks/exhaustive-deps const handleComplete = useCallback(async () => { @@ -116,7 +61,6 @@ export default function OnboardingModal() { onInteractOutside={e => e.preventDefault()} onEscapeKeyDown={e => e.preventDefault()} > - {/* Header + step dots */} {isProgressStep && (
@@ -129,15 +73,8 @@ export default function OnboardingModal() {
)} - {/* Step content */} -
- {step === "welcome" && ( - - )} +
+ {step === "welcome" && } {step === "role" && ( 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 index cc9c97977..500ec76be 100644 --- a/components/Onboarding/OnboardingPulseStep.tsx +++ b/components/Onboarding/OnboardingPulseStep.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; const PULSE_BENEFITS = [ { icon: "📈", text: "Daily streaming performance summaries" }, @@ -72,16 +73,11 @@ export function OnboardingPulseStep({ enabled, onToggle, onNext, onBack }: Props ))}
-
- - -
+
); } diff --git a/components/Onboarding/OnboardingRoleStep.tsx b/components/Onboarding/OnboardingRoleStep.tsx index fb5ac67a0..68e995fa7 100644 --- a/components/Onboarding/OnboardingRoleStep.tsx +++ b/components/Onboarding/OnboardingRoleStep.tsx @@ -3,15 +3,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { motion } from "motion/react"; - -const ROLES = [ - { id: "artist_manager", label: "Artist Manager", icon: "🎯", description: "I manage one or more artists" }, - { id: "label", label: "Record Label", icon: "🏷️", description: "I run a label or imprint" }, - { id: "artist", label: "Artist", icon: "🎤", description: "I'm the artist" }, - { id: "publisher", label: "Publisher", icon: "📝", description: "I handle publishing & sync" }, - { id: "dsp", label: "DSP / Platform", icon: "📱", description: "I work at a streaming platform" }, - { id: "other", label: "Other", icon: "✨", description: "Something else entirely" }, -]; +import { ONBOARDING_ROLES } from "./onboardingRoleConfig"; interface Props { selected: string | undefined; @@ -33,7 +25,7 @@ export function OnboardingRoleStep({ selected, onSelect, onNext }: Props) {
- {ROLES.map((role, i) => ( + {ONBOARDING_ROLES.map((role, i) => ( ; @@ -110,10 +111,11 @@ export function OnboardingTasksStep({ roleType, artistNames, onNext, onBack }: P ))}
-
- - -
+
); } 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/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 }; +} From af097bc099c5b14bd709bda30dae96e2ffd4b75d Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 12:14:07 +0000 Subject: [PATCH 5/7] agent: address feedback --- .../Onboarding/OnboardingArtistsStep.tsx | 2 - .../Onboarding/OnboardingContextStep.tsx | 1 - components/Onboarding/OnboardingModal.tsx | 18 +--- components/Onboarding/OnboardingPulseStep.tsx | 1 - .../Onboarding/OnboardingResearchingStep.tsx | 96 ------------------- components/Onboarding/OnboardingStepDots.tsx | 11 +-- components/Onboarding/OnboardingTasksStep.tsx | 1 - 7 files changed, 5 insertions(+), 125 deletions(-) delete mode 100644 components/Onboarding/OnboardingResearchingStep.tsx diff --git a/components/Onboarding/OnboardingArtistsStep.tsx b/components/Onboarding/OnboardingArtistsStep.tsx index 2baeea9d4..1ba8fe5f0 100644 --- a/components/Onboarding/OnboardingArtistsStep.tsx +++ b/components/Onboarding/OnboardingArtistsStep.tsx @@ -1,9 +1,7 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Music2, Search, Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; import { OnboardingNavButtons } from "./OnboardingNavButtons"; import { getRoleConfig } from "./onboardingRoleConfig"; import { useSpotifyArtistSearch, type SpotifyArtist } from "./useSpotifyArtistSearch"; diff --git a/components/Onboarding/OnboardingContextStep.tsx b/components/Onboarding/OnboardingContextStep.tsx index 6d0082c8c..b2191ab4e 100644 --- a/components/Onboarding/OnboardingContextStep.tsx +++ b/components/Onboarding/OnboardingContextStep.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect } from "react"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useUserProvider } from "@/providers/UserProvder"; diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 0d920eb9b..5ab1489aa 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { useOnboarding } from "./useOnboarding"; +import { useOnboarding, type OnboardingStep } from "./useOnboarding"; import { useOnboardingPersist } from "./useOnboardingPersist"; import { OnboardingWelcomeStep } from "./OnboardingWelcomeStep"; import { OnboardingRoleStep } from "./OnboardingRoleStep"; @@ -14,17 +14,7 @@ import { OnboardingTasksStep } from "./OnboardingTasksStep"; import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; import { OnboardingStepDots } from "./OnboardingStepDots"; -type Step = - | "welcome" - | "role" - | "context" - | "artists" - | "connections" - | "pulse" - | "tasks" - | "complete"; - -const PROGRESS_STEPS: Step[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; +const PROGRESS_STEPS: OnboardingStep[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; /** * Onboarding wizard orchestrator. @@ -52,7 +42,7 @@ export default function OnboardingModal() { } }, [complete, data.artists]); - const isProgressStep = PROGRESS_STEPS.includes(step as Step); + const isProgressStep = PROGRESS_STEPS.includes(step as OnboardingStep); return ( {}}> @@ -66,7 +56,7 @@ export default function OnboardingModal() {
Recoupable - {PROGRESS_STEPS.indexOf(step as Step) + 1} of {PROGRESS_STEPS.length} + {PROGRESS_STEPS.indexOf(step as OnboardingStep) + 1} of {PROGRESS_STEPS.length}
diff --git a/components/Onboarding/OnboardingPulseStep.tsx b/components/Onboarding/OnboardingPulseStep.tsx index 500ec76be..08638be3c 100644 --- a/components/Onboarding/OnboardingPulseStep.tsx +++ b/components/Onboarding/OnboardingPulseStep.tsx @@ -1,6 +1,5 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { OnboardingNavButtons } from "./OnboardingNavButtons"; diff --git a/components/Onboarding/OnboardingResearchingStep.tsx b/components/Onboarding/OnboardingResearchingStep.tsx deleted file mode 100644 index 7b448d2ea..000000000 --- a/components/Onboarding/OnboardingResearchingStep.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { cn } from "@/lib/utils"; - -const RESEARCH_STAGES = [ - { label: "Analyzing fan segments & demographics", icon: "📊" }, - { label: "Pulling streaming data & release history", icon: "🎵" }, - { label: "Scanning upcoming tour & release windows", icon: "📅" }, - { label: "Benchmarking against similar artists", icon: "🔍" }, - { label: "Generating proactive task recommendations", icon: "✅" }, - { label: "Building your artist intelligence files", icon: "🧠" }, -]; - -interface Props { - artistNames: string[]; - onComplete: () => void; -} - -export function OnboardingResearchingStep({ artistNames, onComplete }: Props) { - const [currentStage, setCurrentStage] = useState(0); - const [completedStages, setCompletedStages] = useState([]); - - useEffect(() => { - const interval = setInterval(() => { - setCompletedStages(prev => [...prev, currentStage]); - setCurrentStage(prev => { - const next = prev + 1; - if (next >= RESEARCH_STAGES.length) { - clearInterval(interval); - setTimeout(onComplete, 600); - return prev; - } - return next; - }); - }, 900); - - return () => clearInterval(interval); - }, [onComplete]); // eslint-disable-line react-hooks/exhaustive-deps - - const displayArtists = artistNames.slice(0, 3); - const overflow = artistNames.length - 3; - - return ( -
- {/* Animated logo / pulse */} -
-
-
-
- 🧠 -
-
- -
-

- Running deep research - {displayArtists.length > 0 && ( - <> - {" on "} - - {displayArtists.join(", ")} - {overflow > 0 && ` +${overflow} more`} - - - )} -

-

- This usually takes just a moment. Get ready to be impressed. -

-
- - {/* Stage progress */} -
- {RESEARCH_STAGES.map((stage, i) => ( -
- - {completedStages.includes(i) ? "✅" : stage.icon} - - {stage.label} -
- ))} -
-
- ); -} diff --git a/components/Onboarding/OnboardingStepDots.tsx b/components/Onboarding/OnboardingStepDots.tsx index fbb46f7b9..11651b4ea 100644 --- a/components/Onboarding/OnboardingStepDots.tsx +++ b/components/Onboarding/OnboardingStepDots.tsx @@ -1,14 +1,5 @@ import { cn } from "@/lib/utils"; - -type OnboardingStep = - | "welcome" - | "role" - | "context" - | "artists" - | "connections" - | "pulse" - | "tasks" - | "complete"; +import type { OnboardingStep } from "./useOnboarding"; const STEPS: { id: OnboardingStep; label: string }[] = [ { id: "role", label: "Role" }, diff --git a/components/Onboarding/OnboardingTasksStep.tsx b/components/Onboarding/OnboardingTasksStep.tsx index 5884d2799..7439d7273 100644 --- a/components/Onboarding/OnboardingTasksStep.tsx +++ b/components/Onboarding/OnboardingTasksStep.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { OnboardingNavButtons } from "./OnboardingNavButtons"; From c5b468d0565c9a29cd40b956671bd80ed10fdbad Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 12:29:44 +0000 Subject: [PATCH 6/7] agent: address feedback --- .../Onboarding/OnboardingCompleteStep.tsx | 46 ++--------- components/Onboarding/OnboardingConfetti.tsx | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 components/Onboarding/OnboardingConfetti.tsx diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx index 0b9e27176..ceb9b4f88 100644 --- a/components/Onboarding/OnboardingCompleteStep.tsx +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +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[]; @@ -12,41 +13,6 @@ interface Props { onComplete: () => void; } -function useConfetti() { - const triggered = useRef(false); - - useEffect(() => { - if (triggered.current) return; - triggered.current = true; - - // Dynamically import canvas-confetti if available, otherwise use CSS fallback - import("canvas-confetti").then(({ default: confetti }) => { - confetti({ - particleCount: 120, - spread: 80, - origin: { y: 0.55 }, - colors: ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f97316"], - }); - setTimeout(() => { - confetti({ - particleCount: 60, - spread: 100, - origin: { y: 0.5, x: 0.2 }, - angle: 60, - }); - confetti({ - particleCount: 60, - spread: 100, - origin: { y: 0.5, x: 0.8 }, - angle: 120, - }); - }, 300); - }).catch(() => { - // canvas-confetti not available — silently skip - }); - }, []); -} - /** * The "aha moment" — everything summarized with Framer Motion reveals + confetti. */ @@ -57,7 +23,6 @@ export function OnboardingCompleteStep({ pulseEnabled, onComplete, }: Props) { - useConfetti(); const [visible, setVisible] = useState(false); useEffect(() => { @@ -87,7 +52,9 @@ export function OnboardingCompleteStep({ return ( {visible && ( - + + - + + )} ); 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 ( + + ); +} From 55116e5d326ea4053af7c97ab5450c82786c905b Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 14:25:25 +0000 Subject: [PATCH 7/7] agent: address feedback --- components/Onboarding/OnboardingModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 5ab1489aa..86ec0a478 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -59,7 +59,7 @@ export default function OnboardingModal() { {PROGRESS_STEPS.indexOf(step as OnboardingStep) + 1} of {PROGRESS_STEPS.length}
- +
)}