From d88963caf7d3e75346157f615f403d28e306b658 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Thu, 12 Mar 2026 03:41:45 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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}
- +
)} From 508792c2c51ab393b71b9f5b59a1e86380aed004 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 14 Mar 2026 12:44:20 +0000 Subject: [PATCH 08/10] fix: save onboarding_status to DB so flow doesn't restart after completion The /api/account/update route was ignoring onboardingStatus and onboardingData from the request body, so onboarding_status was never persisted. On page reload the modal re-opened because the DB still had null. Also moved persist() to run in handleComplete (awaited before redirect) instead of on the tasks step, ensuring fresh data and guaranteed completion before the page navigates away. Co-Authored-By: Claude Sonnet 4.6 --- app/api/account/update/route.tsx | 6 +++++- components/Onboarding/OnboardingModal.tsx | 14 ++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/api/account/update/route.tsx b/app/api/account/update/route.tsx index b3e8fecf5..3a5fa10a3 100644 --- a/app/api/account/update/route.tsx +++ b/app/api/account/update/route.tsx @@ -6,7 +6,7 @@ import updateAccountInfo from "@/lib/supabase/account_info/updateAccountInfo"; export async function POST(req: NextRequest) { const body = await req.json(); - const { instruction, name, organization, accountId, image, jobTitle, roleType, companyName, knowledges } = body; + const { instruction, name, organization, accountId, image, jobTitle, roleType, companyName, knowledges, onboardingStatus, onboardingData } = body; try { const found = await getAccountById(accountId); @@ -26,6 +26,8 @@ export async function POST(req: NextRequest) { role_type: roleType, company_name: companyName, knowledges, + ...(onboardingStatus !== undefined && { onboarding_status: onboardingStatus }), + ...(onboardingData !== undefined && { onboarding_data: onboardingData }), }); } else { await updateAccountInfo(accountId, { @@ -36,6 +38,8 @@ export async function POST(req: NextRequest) { role_type: roleType, company_name: companyName, knowledges, + ...(onboardingStatus !== undefined && { onboarding_status: onboardingStatus }), + ...(onboardingData !== undefined && { onboarding_data: onboardingData }), }); } diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 86ec0a478..93e237893 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useOnboarding, type OnboardingStep } from "./useOnboarding"; import { useOnboardingPersist } from "./useOnboardingPersist"; @@ -24,15 +24,9 @@ export default function OnboardingModal() { const { isOpen, step, data, updateData, nextStep, prevStep, complete } = useOnboarding(); const { persist } = useOnboardingPersist(); - // Persist everything when the tasks (penultimate) step is reached - useEffect(() => { - if (step === "tasks") { - persist(data); - } - }, [step]); // eslint-disable-line react-hooks/exhaustive-deps - const handleComplete = useCallback(async () => { - await complete(); + await persist(data); + complete(); const artistNames = (data.artists ?? []).map(a => a.name); if (artistNames.length > 0) { const q = encodeURIComponent( @@ -40,7 +34,7 @@ export default function OnboardingModal() { ); setTimeout(() => { window.location.href = `/?q=${q}`; }, 200); } - }, [complete, data.artists]); + }, [complete, persist, data]); const isProgressStep = PROGRESS_STEPS.includes(step as OnboardingStep); From c08482b461a2514853de9e2fe9ab7961eead30e9 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 14 Mar 2026 13:24:33 +0000 Subject: [PATCH 09/10] fix: address code review feedback on onboarding flow - handleComplete: wrap persist() in try/catch to gate complete() and redirect on success only - OnboardingModal: deduplicate connectedSlugs using Set to prevent inflated connectedCount from multiple connect events - OnboardingWelcomeStep: clear the auto-advance setTimeout on unmount via useRef to prevent state updates after component is gone - useOnboardingPersist: check response.ok from /api/account/update and throw on 4xx/5xx so persist failures surface to the caller - OnboardingCompleteStep: add isCompleting state to disable the button after first click, preventing duplicate persistence/redirect attempts - OnboardingConfetti: move borderRadius randomization into makeParticles() so particle shapes are stable across re-renders Co-Authored-By: Claude Sonnet 4.6 --- components/Onboarding/OnboardingCompleteStep.tsx | 15 ++++++++++++--- components/Onboarding/OnboardingConfetti.tsx | 4 +++- components/Onboarding/OnboardingModal.tsx | 10 ++++++++-- components/Onboarding/OnboardingWelcomeStep.tsx | 10 +++++++--- components/Onboarding/useOnboardingPersist.ts | 7 +++++-- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx index ceb9b4f88..f79daef21 100644 --- a/components/Onboarding/OnboardingCompleteStep.tsx +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -10,7 +10,7 @@ interface Props { name: string | undefined; connectedCount: number; pulseEnabled: boolean; - onComplete: () => void; + onComplete: () => void | Promise; } /** @@ -24,6 +24,7 @@ export function OnboardingCompleteStep({ onComplete, }: Props) { const [visible, setVisible] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); useEffect(() => { const t = setTimeout(() => setVisible(true), 100); @@ -106,8 +107,16 @@ export function OnboardingCompleteStep({ animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.7 }} > -

Your peers in music will want to know what you're using. diff --git a/components/Onboarding/OnboardingConfetti.tsx b/components/Onboarding/OnboardingConfetti.tsx index 5534ba9d9..4bf916ce5 100644 --- a/components/Onboarding/OnboardingConfetti.tsx +++ b/components/Onboarding/OnboardingConfetti.tsx @@ -14,6 +14,7 @@ interface Particle { delay: number; duration: number; rotateEnd: number; + borderRadius: string; } function makeParticles(): Particle[] { @@ -25,6 +26,7 @@ function makeParticles(): Particle[] { delay: Math.random() * 0.5, duration: 1.2 + Math.random() * 0.8, rotateEnd: (Math.random() - 0.5) * 720, + borderRadius: Math.random() > 0.5 ? "50%" : "2px", })); } @@ -57,7 +59,7 @@ export function OnboardingConfetti() { top: "-10px", width: p.size, height: p.size, - borderRadius: Math.random() > 0.5 ? "50%" : "2px", + borderRadius: p.borderRadius, backgroundColor: p.color, }} initial={{ y: 0, opacity: 1, rotate: 0, scale: 1 }} diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 93e237893..177d8dfc6 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -25,7 +25,11 @@ export default function OnboardingModal() { const { persist } = useOnboardingPersist(); const handleComplete = useCallback(async () => { - await persist(data); + try { + await persist(data); + } catch { + return; + } complete(); const artistNames = (data.artists ?? []).map(a => a.name); if (artistNames.length > 0) { @@ -94,7 +98,9 @@ export default function OnboardingModal() { - updateData({ connectedSlugs: [...(data.connectedSlugs ?? []), slug] }) + updateData({ + connectedSlugs: Array.from(new Set([...(data.connectedSlugs ?? []), slug])), + }) } onNext={nextStep} onBack={prevStep} diff --git a/components/Onboarding/OnboardingWelcomeStep.tsx b/components/Onboarding/OnboardingWelcomeStep.tsx index 4136a98af..0f9c77328 100644 --- a/components/Onboarding/OnboardingWelcomeStep.tsx +++ b/components/Onboarding/OnboardingWelcomeStep.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { motion, AnimatePresence } from "motion/react"; import { useUserProvider } from "@/providers/UserProvder"; @@ -29,6 +29,7 @@ export function OnboardingWelcomeStep({ onDone }: Props) { const { email, userData } = useUserProvider(); const [lineIdx, setLineIdx] = useState(0); const [done, setDone] = useState(false); + const timeoutRef = useRef | null>(null); // Infer first name from email or account name const firstName = @@ -47,13 +48,16 @@ export function OnboardingWelcomeStep({ onDone }: Props) { if (next >= RESEARCH_LINES.length) { clearInterval(interval); setDone(true); - setTimeout(onDone, 700); + timeoutRef.current = setTimeout(onDone, 700); return prev; } return next; }); }, 520); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; }, [onDone]); return ( diff --git a/components/Onboarding/useOnboardingPersist.ts b/components/Onboarding/useOnboardingPersist.ts index 2779b1b47..ed11bb797 100644 --- a/components/Onboarding/useOnboardingPersist.ts +++ b/components/Onboarding/useOnboardingPersist.ts @@ -40,7 +40,7 @@ export function useOnboardingPersist() { } // Persist account info + onboarding status - await fetch("/api/account/update", { + const response = await fetch("/api/account/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -60,7 +60,10 @@ export function useOnboardingPersist() { }, onboardingData: data, }), - }).catch(console.error); + }); + if (!response.ok) { + throw new Error(`Failed to persist onboarding data: ${response.status}`); + } // Refresh artist sidebar if (typeof getArtists === "function") { From 28ae6dbfe551159339486137f67c006a8cfbb586 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 14 Mar 2026 22:49:41 +0000 Subject: [PATCH 10/10] fix: address code review feedback on onboarding connections flow - Defer onConnect until OAuth popup closes to prevent false checkmarks on cancel - Use functional updater in updateData to avoid stale closure dropping concurrent slugs - Show sonner toast on persist error instead of silently swallowing it - Add comment explaining intentional redirect behavior difference (artists vs no artists) Co-Authored-By: Claude Sonnet 4.6 --- .../Onboarding/OnboardingConnectionsStep.tsx | 18 ++++++++++++++---- components/Onboarding/OnboardingModal.tsx | 11 ++++++++--- components/Onboarding/useOnboarding.ts | 12 +++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx index 31e156ac8..599f5b7fb 100644 --- a/components/Onboarding/OnboardingConnectionsStep.tsx +++ b/components/Onboarding/OnboardingConnectionsStep.tsx @@ -58,14 +58,24 @@ export function OnboardingConnectionsStep({ connected, onConnect, onNext, onBack callbackUrl: window.location.href, }); if (url) { - onConnect(slug); - window.open(url, "_blank", "noopener,noreferrer"); + const popup = window.open(url, "_blank", "noopener,noreferrer"); + if (popup) { + // Poll until the OAuth popup closes, then record the connection. + // This prevents marking a connector as connected if the user cancels OAuth. + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + onConnect(slug); + setConnecting(null); + } + }, 500); + return; // setConnecting(null) is handled by the interval above + } } } catch { // silently continue - } finally { - setConnecting(null); } + setConnecting(null); }; return ( diff --git a/components/Onboarding/OnboardingModal.tsx b/components/Onboarding/OnboardingModal.tsx index 177d8dfc6..b32d5129b 100644 --- a/components/Onboarding/OnboardingModal.tsx +++ b/components/Onboarding/OnboardingModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback } from "react"; +import { toast } from "sonner"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useOnboarding, type OnboardingStep } from "./useOnboarding"; import { useOnboardingPersist } from "./useOnboardingPersist"; @@ -28,10 +29,14 @@ export default function OnboardingModal() { try { await persist(data); } catch { + toast.error("Failed to save your preferences. Please try again."); return; } complete(); const artistNames = (data.artists ?? []).map(a => a.name); + // Users with artists are redirected to a pre-filled status-report query for their + // first artist so they get immediate value. Users without artists close the modal + // and land on the default dashboard to explore freely. 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.`, @@ -98,9 +103,9 @@ export default function OnboardingModal() { - updateData({ - connectedSlugs: Array.from(new Set([...(data.connectedSlugs ?? []), slug])), - }) + updateData(prev => ({ + connectedSlugs: Array.from(new Set([...(prev.connectedSlugs ?? []), slug])), + })) } onNext={nextStep} onBack={prevStep} diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts index 6a2e763b8..7484f2238 100644 --- a/components/Onboarding/useOnboarding.ts +++ b/components/Onboarding/useOnboarding.ts @@ -60,9 +60,15 @@ export function useOnboarding() { } }, [userData]); - const updateData = useCallback((patch: Partial) => { - setData(prev => ({ ...prev, ...patch })); - }, []); + const updateData = useCallback( + (patch: Partial | ((prev: Partial) => Partial)) => { + setData(prev => { + const next = typeof patch === "function" ? patch(prev) : patch; + return { ...prev, ...next }; + }); + }, + [], + ); const nextStep = useCallback(() => { setStep(prev => {