diff --git a/components/Home/HomePage.tsx b/components/Home/HomePage.tsx index 325321da0..bb7981c56 100644 --- a/components/Home/HomePage.tsx +++ b/components/Home/HomePage.tsx @@ -4,6 +4,7 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit"; import { Chat } from "../VercelChat/chat"; import { useEffect } from "react"; import { UIMessage } from "ai"; +import OnboardingModal from "@/components/Onboarding/OnboardingModal"; const HomePage = ({ id, @@ -22,6 +23,7 @@ const HomePage = ({ return (
+
); diff --git a/components/Onboarding/OnboardingArtistsStep.tsx b/components/Onboarding/OnboardingArtistsStep.tsx new file mode 100644 index 000000000..1ba8fe5f0 --- /dev/null +++ b/components/Onboarding/OnboardingArtistsStep.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { X, Music2, Search, Loader2 } from "lucide-react"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; +import { getRoleConfig } from "./onboardingRoleConfig"; +import { useSpotifyArtistSearch, type SpotifyArtist } from "./useSpotifyArtistSearch"; +import { useState } from "react"; + +export interface ArtistEntry { + name: string; + spotifyUrl?: string; + imageUrl?: string; +} + +interface Props { + artists: ArtistEntry[]; + onUpdate: (artists: ArtistEntry[]) => void; + onNext: () => void; + onBack: () => void; + roleType?: string; +} + +/** + * Artist step — live Spotify search with avatars, manual fallback. + */ +export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleType }: Props) { + const { query, setQuery, results, searching, clearResults } = useSpotifyArtistSearch(); + const [focused, setFocused] = useState(false); + + const { artistPlaceholder } = getRoleConfig(roleType); + + const addFromSpotify = (a: SpotifyArtist) => { + if (artists.some(x => x.spotifyUrl === a.external_urls.spotify)) return; + onUpdate([ + ...artists, + { + name: a.name, + spotifyUrl: a.external_urls.spotify, + imageUrl: a.images?.[0]?.url, + }, + ]); + clearResults(); + }; + + const addManual = () => { + const trimmed = query.trim(); + if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return; + onUpdate([...artists, { name: trimmed }]); + clearResults(); + }; + + const remove = (idx: number) => onUpdate(artists.filter((_, i) => i !== idx)); + + const showDropdown = focused && (results.length > 0 || (searching && query.length > 1)); + const showManualAdd = focused && query.trim().length > 1 && !searching && results.length === 0; + + return ( +
+
+

Add your priority artists

+

+ We'll run deep research — fan data, release windows, competitive benchmarks + — before you ever open a chat. +

+
+ + {/* Search input */} +
+
+ + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + onKeyDown={e => { + if (e.key === "Enter" && results.length > 0) addFromSpotify(results[0]); + else if (e.key === "Enter" && query.trim()) addManual(); + }} + className="pl-9 pr-9" + /> + {searching && ( + + )} +
+ + {/* Dropdown */} + {showDropdown && ( +
+ {results.map(a => ( + + ))} +
+ )} + + {/* Manual add fallback */} + {showManualAdd && ( +
+ +
+ )} +
+ + {/* Added artists */} + {artists.length > 0 && ( + + )} + +
+ 0 + ? `Research ${artists.length} artist${artists.length > 1 ? "s" : ""} →` + : "Add at least one artist" + } + /> + {artists.length === 0 && ( + + )} +
+
+ ); +} + +/** Small helper components scoped to this file */ + +function ArtistAvatar({ imageUrl, name }: { imageUrl?: string; name?: string }) { + return imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {name + ) : ( +
+ +
+ ); +} + +function ArtistSearchResult({ + artist, + onSelect, +}: { + artist: SpotifyArtist; + onSelect: (a: SpotifyArtist) => void; +}) { + return ( + + ); +} diff --git a/components/Onboarding/OnboardingCompleteStep.tsx b/components/Onboarding/OnboardingCompleteStep.tsx new file mode 100644 index 000000000..ceb9b4f88 --- /dev/null +++ b/components/Onboarding/OnboardingCompleteStep.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { Button } from "@/components/ui/button"; +import { OnboardingConfetti } from "./OnboardingConfetti"; + +interface Props { + artistNames: string[]; + name: string | undefined; + connectedCount: number; + pulseEnabled: boolean; + onComplete: () => void; +} + +/** + * The "aha moment" — everything summarized with Framer Motion reveals + confetti. + */ +export function OnboardingCompleteStep({ + artistNames, + name, + connectedCount, + pulseEnabled, + onComplete, +}: Props) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setVisible(true), 100); + return () => clearTimeout(t); + }, []); + + const summaryItems = [ + artistNames.length > 0 && { + icon: "🎤", + text: `Deep research running on ${artistNames.slice(0, 2).join(" & ")}${ + artistNames.length > 2 ? ` +${artistNames.length - 2} more` : "" + }`, + }, + connectedCount > 0 && { + icon: "🔗", + text: `${connectedCount} platform${connectedCount > 1 ? "s" : ""} connected`, + }, + pulseEnabled && { + icon: "⚡", + text: "Pulse active — briefing arrives tomorrow morning", + }, + { icon: "✅", text: "First week of tasks queued" }, + { icon: "🧠", text: "AI learning your artists and fans right now" }, + ].filter(Boolean) as { icon: string; text: string }[]; + + return ( + + {visible && ( + <> + + + {/* Trophy */} + + 🚀 + + + +

+ {name ? `${name.split(" ")[0]}, you're already ahead.` : "You're already ahead."} +

+

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

+
+ + {/* Summary items */} +
+ {summaryItems.map((item, i) => ( + + {item.icon} + {item.text} + + ))} +
+ + + +

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

+
+
+ + )} +
+ ); +} diff --git a/components/Onboarding/OnboardingConfetti.tsx b/components/Onboarding/OnboardingConfetti.tsx new file mode 100644 index 000000000..5534ba9d9 --- /dev/null +++ b/components/Onboarding/OnboardingConfetti.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "motion/react"; + +const COLORS = ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f97316", "#facc15"]; +const COUNT = 60; + +interface Particle { + id: number; + x: number; + color: string; + size: number; + delay: number; + duration: number; + rotateEnd: number; +} + +function makeParticles(): Particle[] { + return Array.from({ length: COUNT }, (_, i) => ({ + id: i, + x: Math.random() * 100, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + size: 6 + Math.random() * 6, + delay: Math.random() * 0.5, + duration: 1.2 + Math.random() * 0.8, + rotateEnd: (Math.random() - 0.5) * 720, + })); +} + +/** + * Lightweight CSS confetti using Framer Motion — no external package needed. + * Particles rain down from the top of the viewport. + */ +export function OnboardingConfetti() { + const [particles] = useState(makeParticles); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const t = setTimeout(() => setVisible(false), 2500); + return () => clearTimeout(t); + }, []); + + if (!visible) return null; + + return ( + + ); +} diff --git a/components/Onboarding/OnboardingConnectionsStep.tsx b/components/Onboarding/OnboardingConnectionsStep.tsx new file mode 100644 index 000000000..31e156ac8 --- /dev/null +++ b/components/Onboarding/OnboardingConnectionsStep.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, ExternalLink } from "lucide-react"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; +import { cn } from "@/lib/utils"; +import { useAccessToken } from "@/hooks/useAccessToken"; +import { authorizeConnectorApi } from "@/lib/composio/api/authorizeConnectorApi"; + +const FEATURED_CONNECTORS = [ + { + slug: "googlesheets", + name: "Google Sheets", + icon: "📊", + benefit: "Sync fan data and tour reports automatically", + }, + { + slug: "googledrive", + name: "Google Drive", + icon: "📁", + benefit: "Access EPKs, assets and files from your AI", + }, + { + slug: "tiktok", + name: "TikTok", + icon: "🎵", + benefit: "Track viral moments and creator content", + }, + { + slug: "googledocs", + name: "Google Docs", + icon: "📝", + benefit: "Auto-draft press releases and pitch docs", + }, +]; + +interface Props { + connected: string[]; + onConnect: (slug: string) => void; + onNext: () => void; + onBack: () => void; +} + +/** + * Step to connect key platform integrations during onboarding. + */ +export function OnboardingConnectionsStep({ connected, onConnect, onNext, onBack }: Props) { + const accessToken = useAccessToken(); + const [connecting, setConnecting] = useState(null); + + const handleConnect = async (slug: string) => { + if (!accessToken) return; + setConnecting(slug); + try { + const url = await authorizeConnectorApi(accessToken, { + connector: slug, + callbackUrl: window.location.href, + }); + if (url) { + onConnect(slug); + window.open(url, "_blank", "noopener,noreferrer"); + } + } catch { + // silently continue + } finally { + setConnecting(null); + } + }; + + return ( +
+
+

+ Connect your tools +

+

+ Give Recoupable access to the places your work already lives. + Connect what you want — skip the rest. +

+
+ +
+ {FEATURED_CONNECTORS.map(c => { + const isConnected = connected.includes(c.slug); + const isConnecting = connecting === c.slug; + + return ( +
+ {c.icon} +
+

{c.name}

+

{c.benefit}

+
+ {isConnected ? ( + + ) : ( + + )} +
+ ); + })} +
+ +
+ 0 ? `Continue with ${connected.length} connected →` : "Skip for now →"} + /> +

+ More connectors available in Settings → Connectors +

+
+
+ ); +} diff --git a/components/Onboarding/OnboardingContextStep.tsx b/components/Onboarding/OnboardingContextStep.tsx new file mode 100644 index 000000000..b2191ab4e --- /dev/null +++ b/components/Onboarding/OnboardingContextStep.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useUserProvider } from "@/providers/UserProvder"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; +import { getRoleConfig } from "./onboardingRoleConfig"; + +interface Props { + name: string | undefined; + companyName: string | undefined; + roleType: string | undefined; + onChangeName: (v: string) => void; + onChangeCompany: (v: string) => void; + onNext: () => void; + onBack: () => void; +} + +/** + * Context step — pre-fills name from Privy, adapts company label to role. + */ +export function OnboardingContextStep({ + name, + companyName, + roleType, + onChangeName, + onChangeCompany, + onNext, + onBack, +}: Props) { + const { userData, email } = useUserProvider(); + + // Pre-fill name from account data or email + useEffect(() => { + if (!name && (userData?.name || email)) { + const inferred = + userData?.name || + (email + ? email + .split("@")[0] + .replace(/[._]/g, " ") + .replace(/\b\w/g, c => c.toUpperCase()) + : ""); + if (inferred) onChangeName(inferred); + } + }, [userData?.name, email]); // eslint-disable-line react-hooks/exhaustive-deps + + const { companyLabel } = getRoleConfig(roleType); + + return ( +
+
+

Quick intro

+

+ We'll use this to personalize your workspace. +

+
+ +
+
+ + 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 new file mode 100644 index 000000000..86ec0a478 --- /dev/null +++ b/components/Onboarding/OnboardingModal.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { useOnboarding, type OnboardingStep } from "./useOnboarding"; +import { useOnboardingPersist } from "./useOnboardingPersist"; +import { OnboardingWelcomeStep } from "./OnboardingWelcomeStep"; +import { OnboardingRoleStep } from "./OnboardingRoleStep"; +import { OnboardingContextStep } from "./OnboardingContextStep"; +import { OnboardingArtistsStep } from "./OnboardingArtistsStep"; +import { OnboardingConnectionsStep } from "./OnboardingConnectionsStep"; +import { OnboardingPulseStep } from "./OnboardingPulseStep"; +import { OnboardingTasksStep } from "./OnboardingTasksStep"; +import { OnboardingCompleteStep } from "./OnboardingCompleteStep"; +import { OnboardingStepDots } from "./OnboardingStepDots"; + +const PROGRESS_STEPS: OnboardingStep[] = ["role", "context", "artists", "connections", "pulse", "tasks"]; + +/** + * Onboarding wizard orchestrator. + * Delegates persistence to useOnboardingPersist and step state to useOnboarding. + */ +export default function OnboardingModal() { + const { isOpen, step, data, updateData, nextStep, prevStep, complete } = useOnboarding(); + const { persist } = useOnboardingPersist(); + + // Persist everything when the tasks (penultimate) step is reached + useEffect(() => { + if (step === "tasks") { + persist(data); + } + }, [step]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleComplete = useCallback(async () => { + await complete(); + const artistNames = (data.artists ?? []).map(a => a.name); + if (artistNames.length > 0) { + const q = encodeURIComponent( + `Give me a complete status report for ${artistNames[0]} — fan breakdown, streaming performance, top opportunities, and my 3 highest-priority actions this week.`, + ); + setTimeout(() => { window.location.href = `/?q=${q}`; }, 200); + } + }, [complete, data.artists]); + + const isProgressStep = PROGRESS_STEPS.includes(step as OnboardingStep); + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={e => e.preventDefault()} + > + {isProgressStep && ( +
+
+ Recoupable + + {PROGRESS_STEPS.indexOf(step as OnboardingStep) + 1} of {PROGRESS_STEPS.length} + +
+ +
+ )} + +
+ {step === "welcome" && } + + {step === "role" && ( + updateData({ roleType: v })} + onNext={nextStep} + /> + )} + + {step === "context" && ( + updateData({ name: v })} + onChangeCompany={v => updateData({ companyName: v })} + onNext={nextStep} + onBack={prevStep} + /> + )} + + {step === "artists" && ( + updateData({ artists })} + onNext={nextStep} + onBack={prevStep} + roleType={data.roleType} + /> + )} + + {step === "connections" && ( + + updateData({ connectedSlugs: [...(data.connectedSlugs ?? []), slug] }) + } + onNext={nextStep} + onBack={prevStep} + /> + )} + + {step === "pulse" && ( + updateData({ pulseEnabled: v })} + onNext={nextStep} + onBack={prevStep} + /> + )} + + {step === "tasks" && ( + a.name)} + onNext={nextStep} + onBack={prevStep} + /> + )} + + {step === "complete" && ( + a.name)} + name={data.name} + connectedCount={(data.connectedSlugs ?? []).length} + pulseEnabled={data.pulseEnabled ?? false} + onComplete={handleComplete} + /> + )} +
+
+
+ ); +} diff --git a/components/Onboarding/OnboardingNavButtons.tsx b/components/Onboarding/OnboardingNavButtons.tsx new file mode 100644 index 000000000..754e030c8 --- /dev/null +++ b/components/Onboarding/OnboardingNavButtons.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; + +interface OnboardingNavButtonsProps { + onBack: () => void; + onNext: () => void; + nextLabel?: string; + nextDisabled?: boolean; +} + +/** + * Reusable back/next navigation row used across onboarding steps. + */ +export function OnboardingNavButtons({ + onBack, + onNext, + nextLabel = "Continue →", + nextDisabled = false, +}: OnboardingNavButtonsProps) { + return ( +
+ + +
+ ); +} diff --git a/components/Onboarding/OnboardingPulseStep.tsx b/components/Onboarding/OnboardingPulseStep.tsx new file mode 100644 index 000000000..08638be3c --- /dev/null +++ b/components/Onboarding/OnboardingPulseStep.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; + +const PULSE_BENEFITS = [ + { icon: "📈", text: "Daily streaming performance summaries" }, + { icon: "🔔", text: "Alerts when an artist spikes on social" }, + { icon: "🎯", text: "Weekly priority actions for each artist" }, + { icon: "📬", text: "Delivered to your inbox every morning" }, +]; + +interface Props { + enabled: boolean; + onToggle: (v: boolean) => void; + onNext: () => void; + onBack: () => void; +} + +/** + * Onboarding step to activate Pulse — daily AI briefings per artist. + */ +export function OnboardingPulseStep({ enabled, onToggle, onNext, onBack }: Props) { + return ( +
+
+
+ +

Turn on Pulse

+
+

+ Pulse sends you an AI-generated daily briefing on each of your artists — + so you always know what to do next before you even open the app. +

+
+ + {/* Toggle card */} +
onToggle(!enabled)} + > +
+
+

Daily Artist Intelligence

+

+ {enabled ? "Active — you'll get your first briefing tomorrow morning." : "Off — tap to activate."} +

+
+ e.stopPropagation()} /> +
+
+ + {/* Benefits list */} +
+ {PULSE_BENEFITS.map((b, i) => ( +
+ {b.icon} + {b.text} +
+ ))} +
+ + +
+ ); +} diff --git a/components/Onboarding/OnboardingRoleStep.tsx b/components/Onboarding/OnboardingRoleStep.tsx new file mode 100644 index 000000000..68e995fa7 --- /dev/null +++ b/components/Onboarding/OnboardingRoleStep.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { motion } from "motion/react"; +import { ONBOARDING_ROLES } from "./onboardingRoleConfig"; + +interface Props { + selected: string | undefined; + onSelect: (role: string) => void; + onNext: () => void; +} + +export function OnboardingRoleStep({ selected, onSelect, onNext }: Props) { + return ( +
+ +

What best describes you?

+

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

+
+ +
+ {ONBOARDING_ROLES.map((role, i) => ( + onSelect(role.id)} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: i * 0.05 }} + className={cn( + "flex flex-col items-start gap-1 rounded-xl border-2 p-4 text-left transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + selected === role.id + ? "border-primary bg-primary/10" + : "border-border bg-muted/30", + )} + > + {role.icon} + {role.label} + {role.description} + + ))} +
+ + +
+ ); +} diff --git a/components/Onboarding/OnboardingStepDots.tsx b/components/Onboarding/OnboardingStepDots.tsx new file mode 100644 index 000000000..11651b4ea --- /dev/null +++ b/components/Onboarding/OnboardingStepDots.tsx @@ -0,0 +1,58 @@ +import { cn } from "@/lib/utils"; +import type { OnboardingStep } from "./useOnboarding"; + +const STEPS: { id: OnboardingStep; label: string }[] = [ + { id: "role", label: "Role" }, + { id: "context", label: "You" }, + { id: "artists", label: "Artists" }, + { id: "connections", label: "Connect" }, + { id: "pulse", label: "Pulse" }, + { id: "tasks", label: "Tasks" }, +]; + +interface Props { + current: OnboardingStep; +} + +/** + * Visual step progress dots with labels for the onboarding wizard. + */ +export function OnboardingStepDots({ current }: Props) { + const currentIdx = STEPS.findIndex(s => s.id === current); + + return ( +
+ {STEPS.map((step, i) => { + const isCompleted = i < currentIdx; + const isCurrent = i === currentIdx; + + return ( +
+ {/* Dot */} +
+
+
+ {/* Connector line */} + {i < STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/components/Onboarding/OnboardingTasksStep.tsx b/components/Onboarding/OnboardingTasksStep.tsx new file mode 100644 index 000000000..7439d7273 --- /dev/null +++ b/components/Onboarding/OnboardingTasksStep.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { OnboardingNavButtons } from "./OnboardingNavButtons"; + +type RoleTaskMap = Record; + +const ROLE_TASKS: RoleTaskMap = { + artist_manager: [ + { icon: "📊", title: "Fan segment deep dive", desc: "Identify top 3 growth markets for each artist" }, + { icon: "📅", title: "Release window analysis", desc: "Find the best drop timing based on streaming patterns" }, + { icon: "🤝", title: "Playlist pitch list", desc: "Build a targeted pitch list for the next release" }, + { icon: "📱", title: "Social content calendar", desc: "Generate a 2-week content plan per platform" }, + ], + label: [ + { icon: "📈", title: "Roster performance report", desc: "Weekly streaming & social benchmarks across roster" }, + { icon: "💰", title: "Sync opportunity scan", desc: "Match catalog tracks to current placement briefs" }, + { icon: "🎯", title: "Market expansion analysis", desc: "Find untapped territories for priority artists" }, + { icon: "📋", title: "A&R brief generator", desc: "Auto-draft artist development briefs from data" }, + ], + artist: [ + { icon: "🎵", title: "Release strategy builder", desc: "Plan your next drop from concept to launch" }, + { icon: "📊", title: "Audience growth playbook", desc: "Tailored strategy based on your current fanbase" }, + { icon: "💬", title: "Press outreach list", desc: "Curated list of blogs, editors, and tastemakers" }, + { icon: "📱", title: "Content idea generator", desc: "30 days of social content ideas from your catalog" }, + ], + publisher: [ + { icon: "🔍", title: "Sync pitch tracker", desc: "Track placement opportunities by catalog title" }, + { icon: "📊", title: "Royalty performance scan", desc: "Flag underperforming assets for renegotiation" }, + { icon: "🤝", title: "Sub-publisher match", desc: "Find territory reps based on catalog genre profile" }, + { icon: "📝", title: "Admin review checklist", desc: "Audit registration completeness across catalog" }, + ], + dsp: [ + { icon: "🎯", title: "Emerging artist radar", desc: "Surface breakout acts based on velocity signals" }, + { icon: "📊", title: "Genre trend report", desc: "Identify rising micro-genres ahead of the curve" }, + { icon: "🤝", title: "Label partnership brief", desc: "Pitch deck framework for priority label meetings" }, + { icon: "🔔", title: "Cultural moment tracker", desc: "Link playlisting opportunities to trending moments" }, + ], + other: [ + { icon: "🗓️", title: "Weekly priorities briefing", desc: "AI-curated to-do list based on your artists" }, + { icon: "📊", title: "Artist performance snapshot", desc: "At-a-glance metrics across all your artists" }, + { icon: "💡", title: "Opportunity alerts", desc: "Notify you when an artist hits a key milestone" }, + { icon: "📝", title: "Smart notes assistant", desc: "Turn meeting notes into action items instantly" }, + ], +}; + +const DEFAULT_TASKS = ROLE_TASKS["artist_manager"]; + +interface Props { + roleType: string | undefined; + artistNames: string[]; + onNext: () => void; + onBack: () => void; +} + +/** + * Shows auto-generated tasks tailored to the user's role + artists, + * giving them an immediate preview of what Recoupable will do for them. + */ +export function OnboardingTasksStep({ roleType, artistNames, onNext, onBack }: Props) { + const tasks = ROLE_TASKS[roleType ?? ""] ?? DEFAULT_TASKS; + const [revealed, setRevealed] = useState([]); + + useEffect(() => { + let i = 0; + const interval = setInterval(() => { + setRevealed(prev => [...prev, i]); + i++; + if (i >= tasks.length) clearInterval(interval); + }, 180); + return () => clearInterval(interval); + }, [tasks.length]); + + const displayArtist = artistNames[0] ?? "your artists"; + + return ( +
+
+

+ Your first week, already planned +

+

+ Based on your role and{" "} + {displayArtist}, + we've queued these to run automatically. +

+
+ +
+ {tasks.map((task, i) => ( +
+ {task.icon} +
+

{task.title}

+

{task.desc}

+
+
+ Queued ✓ +
+
+ ))} +
+ + +
+ ); +} diff --git a/components/Onboarding/OnboardingWelcomeStep.tsx b/components/Onboarding/OnboardingWelcomeStep.tsx new file mode 100644 index 000000000..4136a98af --- /dev/null +++ b/components/Onboarding/OnboardingWelcomeStep.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { useUserProvider } from "@/providers/UserProvder"; + +const RESEARCH_LINES = [ + "Identifying your industry footprint…", + "Scanning music business context…", + "Preparing your personalized workspace…", + "Queueing artist intelligence tasks…", + "Almost ready to change your workflow forever…", +]; + +const SOCIAL_PROOF = [ + "10,000+ artists managed", + "50+ labels onboarded", + "Used by teams at UMG, Def Jam & more", +]; + +interface Props { + onDone: () => void; +} + +/** + * Animated welcome screen — uses Privy name/email, shows social proof, auto-advances. + */ +export function OnboardingWelcomeStep({ onDone }: Props) { + const { email, userData } = useUserProvider(); + const [lineIdx, setLineIdx] = useState(0); + const [done, setDone] = useState(false); + + // Infer first name from email or account name + const firstName = + userData?.name?.split(" ")[0] || + (email ? email.split("@")[0].replace(/[._]/g, " ").split(" ")[0] : null); + + // Capitalize first letter + const displayName = firstName + ? firstName.charAt(0).toUpperCase() + firstName.slice(1) + : null; + + useEffect(() => { + const interval = setInterval(() => { + setLineIdx(prev => { + const next = prev + 1; + if (next >= RESEARCH_LINES.length) { + clearInterval(interval); + setDone(true); + setTimeout(onDone, 700); + return prev; + } + return next; + }); + }, 520); + return () => clearInterval(interval); + }, [onDone]); + + return ( +
+ {/* Animated orb */} +
+
+
+ + 🎵 + +
+ + {/* Headline */} + +

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

+

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

+
+ + {/* Social proof */} + + {SOCIAL_PROOF.map((s, i) => ( + + {s} + + ))} + + + {/* Animated research lines */} +
+ + {RESEARCH_LINES.map((line, i) => + i <= lineIdx ? ( + + + {i < lineIdx ? "✅" : "⏳"} + + + {line} + + + ) : null, + )} + +
+ + {done && ( + + Let's set you up → + + )} +
+ ); +} diff --git a/components/Onboarding/onboardingRoleConfig.ts b/components/Onboarding/onboardingRoleConfig.ts new file mode 100644 index 000000000..ca213553f --- /dev/null +++ b/components/Onboarding/onboardingRoleConfig.ts @@ -0,0 +1,81 @@ +/** + * Single source of truth for all role-related configuration used across + * the onboarding flow. Import from here instead of defining per-file. + */ + +export interface RoleConfig { + id: string; + label: string; + icon: string; + description: string; + companyLabel: string; + artistPlaceholder: string; +} + +export const ONBOARDING_ROLES: RoleConfig[] = [ + { + id: "artist_manager", + label: "Artist Manager", + icon: "🎯", + description: "I manage one or more artists", + companyLabel: "Management company", + artistPlaceholder: "Search for an artist you manage…", + }, + { + id: "label", + label: "Record Label", + icon: "🏷️", + description: "I run a label or imprint", + companyLabel: "Label name", + artistPlaceholder: "Search for a roster artist…", + }, + { + id: "artist", + label: "Artist", + icon: "🎤", + description: "I'm the artist", + companyLabel: "Your artist name or team", + artistPlaceholder: "Search for yourself or a collaborator…", + }, + { + id: "publisher", + label: "Publisher", + icon: "📝", + description: "I handle publishing & sync", + companyLabel: "Publishing company", + artistPlaceholder: "Search for a catalog artist…", + }, + { + id: "dsp", + label: "DSP / Platform", + icon: "📱", + description: "I work at a streaming platform", + companyLabel: "Company / Platform", + artistPlaceholder: "Search for an artist…", + }, + { + id: "other", + label: "Other", + icon: "✨", + description: "Something else entirely", + companyLabel: "Company or organization", + artistPlaceholder: "Search for an artist…", + }, +]; + +export const ROLE_CONFIG_MAP = Object.fromEntries( + ONBOARDING_ROLES.map(r => [r.id, r]), +) as Record; + +export function getRoleConfig(roleId: string | undefined): RoleConfig { + return ( + ROLE_CONFIG_MAP[roleId ?? ""] ?? { + id: "other", + label: "Other", + icon: "✨", + description: "", + companyLabel: "Company", + artistPlaceholder: "Search for an artist…", + } + ); +} diff --git a/components/Onboarding/useOnboarding.ts b/components/Onboarding/useOnboarding.ts new file mode 100644 index 000000000..6a2e763b8 --- /dev/null +++ b/components/Onboarding/useOnboarding.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useUserProvider } from "@/providers/UserProvder"; + +export type OnboardingStep = + | "welcome" // Step 0: Animated welcome / research-in-progress on the user + | "role" // Step 1: Who are you? + | "context" // Step 2: Name, company, vibe + | "artists" // Step 3: Priority artists + | "connections" // Step 4: Connect key platforms + | "pulse" // Step 5: Turn on Pulse + | "tasks" // Step 6: Preview auto-generated tasks + | "complete"; // Step 7: Aha moment reveal + +export interface OnboardingArtist { + name: string; + spotifyUrl?: string; +} + +export interface OnboardingData { + roleType: string; + companyName: string; + name: string; + artists: OnboardingArtist[]; + connectedSlugs: string[]; + pulseEnabled: boolean; +} + +const STEP_ORDER: OnboardingStep[] = [ + "welcome", + "role", + "context", + "artists", + "connections", + "pulse", + "tasks", + "complete", +]; + +/** + * Drives the onboarding wizard. + * Fires once for new accounts (onboarding_status not completed). + */ +export function useOnboarding() { + const { userData } = useUserProvider(); + const [isOpen, setIsOpen] = useState(false); + const [step, setStep] = useState("welcome"); + const [data, setData] = useState>({ + artists: [], + connectedSlugs: [], + pulseEnabled: false, + }); + + useEffect(() => { + if (!userData) return; + const status = userData.onboarding_status as Record | null; + if (!status?.completed) { + setIsOpen(true); + } + }, [userData]); + + const updateData = useCallback((patch: Partial) => { + setData(prev => ({ ...prev, ...patch })); + }, []); + + const nextStep = useCallback(() => { + setStep(prev => { + const idx = STEP_ORDER.indexOf(prev); + return STEP_ORDER[idx + 1] ?? "complete"; + }); + }, []); + + const prevStep = useCallback(() => { + setStep(prev => { + const idx = STEP_ORDER.indexOf(prev); + if (idx <= 1) return prev; // can't go before "role" + return STEP_ORDER[idx - 1]; + }); + }, []); + + const complete = useCallback(() => { + setIsOpen(false); + }, []); + + return { isOpen, step, data, updateData, nextStep, prevStep, complete }; +} diff --git a/components/Onboarding/useOnboardingPersist.ts b/components/Onboarding/useOnboardingPersist.ts new file mode 100644 index 000000000..2779b1b47 --- /dev/null +++ b/components/Onboarding/useOnboardingPersist.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useCallback } from "react"; +import { useUserProvider } from "@/providers/UserProvder"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useAccessToken } from "@/hooks/useAccessToken"; +import saveArtist from "@/lib/saveArtist"; +import { updatePulse } from "@/lib/pulse/updatePulse"; +import type { OnboardingData } from "./useOnboarding"; + +/** + * Handles all side-effects when the onboarding wizard completes: + * - Creates priority artists in the DB + * - Activates Pulse if the user opted in + * - Persists role, name, company, and onboarding status to account_info + * - Refreshes the artist sidebar + */ +export function useOnboardingPersist() { + const { userData } = useUserProvider(); + const { getArtists } = useArtistProvider(); + const accessToken = useAccessToken(); + + const persist = useCallback( + async (data: Partial) => { + if (!userData?.account_id) return; + + // Create artists in parallel + const artistPromises = (data.artists ?? []).map(a => + saveArtist({ + name: a.name, + spotifyUrl: a.spotifyUrl, + accountId: userData.account_id, + }).catch(console.error), + ); + await Promise.allSettled(artistPromises); + + // Activate Pulse if opted in + if (data.pulseEnabled && accessToken) { + await updatePulse({ accessToken, active: true }).catch(console.error); + } + + // Persist account info + onboarding status + await fetch("/api/account/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: userData.account_id, + roleType: data.roleType, + name: data.name, + companyName: data.companyName, + onboardingStatus: { + completed: true, + completedAt: new Date().toISOString(), + steps: { + role: data.roleType, + artistCount: (data.artists ?? []).length, + connectedCount: (data.connectedSlugs ?? []).length, + pulseEnabled: data.pulseEnabled, + }, + }, + onboardingData: data, + }), + }).catch(console.error); + + // Refresh artist sidebar + if (typeof getArtists === "function") { + await getArtists().catch(console.error); + } + }, + [userData?.account_id, accessToken, getArtists], + ); + + return { persist }; +} diff --git a/components/Onboarding/useSpotifyArtistSearch.ts b/components/Onboarding/useSpotifyArtistSearch.ts new file mode 100644 index 000000000..931867440 --- /dev/null +++ b/components/Onboarding/useSpotifyArtistSearch.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { NEW_API_BASE_URL } from "@/lib/consts"; +import { useAccessToken } from "@/hooks/useAccessToken"; + +export interface SpotifyArtist { + id: string; + name: string; + external_urls: { spotify: string }; + images: { url: string }[]; + followers: { total: number }; +} + +/** + * Debounced Spotify artist search hook. + * Encapsulates all search state, debouncing, and API fetching logic. + */ +export function useSpotifyArtistSearch() { + const accessToken = useAccessToken(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const debounceRef = useRef | null>(null); + + const search = useCallback( + async (q: string) => { + if (!q.trim() || q.length < 2) { + setResults([]); + return; + } + setSearching(true); + try { + const params = new URLSearchParams({ q, type: "artist", limit: "5" }); + const url = `${NEW_API_BASE_URL}/api/spotify/search?${params}`; + const headers: HeadersInit = accessToken + ? { Authorization: `Bearer ${accessToken}` } + : {}; + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error("search failed"); + const data = await res.json(); + setResults(data?.artists?.items ?? []); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, + [accessToken], + ); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => search(query), 320); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, search]); + + const clearResults = useCallback(() => { + setResults([]); + setQuery(""); + }, []); + + return { query, setQuery, results, searching, clearResults }; +}