diff --git a/app/api/account/update/route.tsx b/app/api/account/update/route.tsx index b3e8fecf5..3798a1ff2 100644 --- a/app/api/account/update/route.tsx +++ b/app/api/account/update/route.tsx @@ -6,7 +6,19 @@ 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, + onboarding_data, + onboarding_status, + } = body; try { const found = await getAccountById(accountId); @@ -26,6 +38,8 @@ export async function POST(req: NextRequest) { role_type: roleType, company_name: companyName, knowledges, + onboarding_data, + onboarding_status, }); } else { await updateAccountInfo(accountId, { @@ -36,6 +50,8 @@ export async function POST(req: NextRequest) { role_type: roleType, company_name: companyName, knowledges, + onboarding_data, + onboarding_status, }); } diff --git a/app/api/onboarding-templates/route.ts b/app/api/onboarding-templates/route.ts new file mode 100644 index 000000000..fce1843fc --- /dev/null +++ b/app/api/onboarding-templates/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { fetchOnboardingTemplates } from "@/lib/onboarding/fetchOnboardingTemplates"; + +export const runtime = "edge"; + +/** + * GET /api/onboarding-templates + * Fetches all onboarding templates (system templates with 'onboarding' tag) + */ +export async function GET() { + try { + const templates = await fetchOnboardingTemplates(); + return NextResponse.json(templates); + } catch (error) { + console.error("Error fetching onboarding templates:", error); + return NextResponse.json( + { error: "Failed to fetch onboarding templates" }, + { status: 500 } + ); + } +} + +export const dynamic = "force-dynamic"; +export const revalidate = 0; diff --git a/app/onboarding/layout.tsx b/app/onboarding/layout.tsx new file mode 100644 index 000000000..558571824 --- /dev/null +++ b/app/onboarding/layout.tsx @@ -0,0 +1,13 @@ +export default function OnboardingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx new file mode 100644 index 000000000..bd404dc81 --- /dev/null +++ b/app/onboarding/page.tsx @@ -0,0 +1,5 @@ +import OnboardingFlow from "@/components/Onboarding/OnboardingFlow"; + +export default function OnboardingPage() { + return ; +} diff --git a/components/Onboarding/OnboardingFlow.tsx b/components/Onboarding/OnboardingFlow.tsx new file mode 100644 index 000000000..9aa95b76d --- /dev/null +++ b/components/Onboarding/OnboardingFlow.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useOnboarding, type OnboardingStep } from "@/hooks/useOnboarding"; +import OnboardingProgress from "./OnboardingProgress"; +import { + WelcomeStep, + RoleStep, + ArtistsStep, + TaskPickerStep, + RunningStep, + ResultStep, + RecurringStep, + CompleteStep, +} from "./steps"; + +interface StepComponentProps { + onNext: () => void; + onBack: () => void; +} + +type StepComponent = React.ComponentType; + +const STEP_COMPONENTS: Record = { + welcome: WelcomeStep, + role: RoleStep, + artists: ArtistsStep, + "task-picker": TaskPickerStep, + running: RunningStep, + result: ResultStep, + recurring: RecurringStep, + complete: CompleteStep, +}; + +export default function OnboardingFlow() { + const { step, nextStep, prevStep, currentStepIndex, totalSteps } = + useOnboarding(); + + const StepComponent = STEP_COMPONENTS[step]; + + return ( +
+ {/* Progress indicator - rendered above step content */} +
+ +
+ + {/* Current step content */} +
+ +
+
+ ); +} diff --git a/components/Onboarding/OnboardingGuard.tsx b/components/Onboarding/OnboardingGuard.tsx new file mode 100644 index 000000000..d9133ac50 --- /dev/null +++ b/components/Onboarding/OnboardingGuard.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { useUserProvider } from "@/providers/UserProvder"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import useAccountOrganizations from "@/hooks/useAccountOrganizations"; +import { needsOnboarding, OnboardingStatus } from "@/lib/onboarding"; + +/** + * OnboardingGuard component that redirects new org users to /onboarding + * if they haven't completed onboarding yet. + * + * Conditions for redirect: + * 1. User has at least one organization + * 2. onboarding_status.completed !== true + * 3. Organization has at least one artist + * 4. Not already on /onboarding route + */ +const OnboardingGuard = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const pathname = usePathname(); + const { userData } = useUserProvider(); + const { artists, isLoading: artistsLoading } = useArtistProvider(); + const { data: organizations, isLoading: orgsLoading } = + useAccountOrganizations(); + const hasChecked = useRef(false); + + useEffect(() => { + // Skip if data is still loading + if (orgsLoading || artistsLoading) { + return; + } + + // Skip if no userData yet + if (!userData) { + return; + } + + // Skip if already on onboarding route + if (pathname?.startsWith("/onboarding")) { + return; + } + + // Skip if already checked this session (to prevent redirect loops) + if (hasChecked.current) { + return; + } + + // Parse onboarding_status from userData + const onboardingStatus = userData.onboarding_status as + | OnboardingStatus + | null + | undefined; + + // Check if org has artists + const orgHasArtists = artists && artists.length > 0; + + const shouldRedirect = needsOnboarding({ + onboardingStatus, + organizations, + orgHasArtists, + }); + + // Mark as checked to prevent re-checking + hasChecked.current = true; + + if (shouldRedirect) { + router.push("/onboarding"); + } + }, [ + userData, + organizations, + artists, + orgsLoading, + artistsLoading, + pathname, + router, + ]); + + return <>{children}; +}; + +export default OnboardingGuard; diff --git a/components/Onboarding/OnboardingProgress.tsx b/components/Onboarding/OnboardingProgress.tsx new file mode 100644 index 000000000..d0e6920c8 --- /dev/null +++ b/components/Onboarding/OnboardingProgress.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { type OnboardingStep } from "@/hooks/useOnboarding"; + +interface OnboardingProgressProps { + currentStep: OnboardingStep; + currentStepIndex: number; + totalSteps: number; + className?: string; +} + +// Brand primary color: #345A5D +const BRAND_PRIMARY = "#345A5D"; + +// Step order for dot display +const STEPS_ORDER: OnboardingStep[] = [ + "welcome", + "role", + "artists", + "task-picker", + "running", + "result", + "recurring", + "complete", +]; + +export default function OnboardingProgress({ + currentStepIndex, + totalSteps, + className, +}: OnboardingProgressProps) { + // Calculate progress percentage + const progressPercentage = ((currentStepIndex + 1) / totalSteps) * 100; + + return ( +
+ {/* Step indicator text */} +
+ + Step {currentStepIndex + 1} of {totalSteps} + + + {Math.round(progressPercentage)}% + +
+ + {/* Progress bar */} +
+
+
+ + {/* Step dots */} +
+ {STEPS_ORDER.map((step, index) => { + const isCompleted = index < currentStepIndex; + const isCurrent = index === currentStepIndex; + + return ( +
+ ); + })} +
+
+ ); +} diff --git a/components/Onboarding/steps/ArtistsStep.tsx b/components/Onboarding/steps/ArtistsStep.tsx new file mode 100644 index 000000000..d7edc6793 --- /dev/null +++ b/components/Onboarding/steps/ArtistsStep.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useOnboarding } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import useAccountOrganizations from "@/hooks/useAccountOrganizations"; +import { useOrganization } from "@/providers/OrganizationProvider"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { ArtistRecord } from "@/types/Artist"; +import { NEW_API_BASE_URL } from "@/lib/consts"; + +interface ArtistsStepProps { + onNext: () => void; + onBack: () => void; +} + +const MAX_ARTISTS = 3; + +export default function ArtistsStep({ onNext, onBack }: ArtistsStepProps) { + const { setPriorityArtists, priorityArtists: savedPriorityArtists } = + useOnboarding(); + const { userData } = useUserProvider(); + const { data: organizations } = useAccountOrganizations(); + const { selectedOrgId } = useOrganization(); + + const [selectedArtists, setSelectedArtists] = useState([]); + const [artists, setArtists] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Get the org name from selected org or first org + const selectedOrg = organizations?.find( + (org) => org.organization_id === selectedOrgId + ); + const orgName = + selectedOrg?.organization_name || + organizations?.[0]?.organization_name || + "your organization"; + const orgId = selectedOrgId || organizations?.[0]?.organization_id; + + // Fetch org artists + const fetchArtists = useCallback(async () => { + if (!userData?.id || !orgId) { + setArtists([]); + setIsLoading(false); + return; + } + + try { + const params = new URLSearchParams({ + accountId: userData.id as string, + orgId: orgId, + }); + + const response = await fetch( + `${NEW_API_BASE_URL}/api/artists?${params.toString()}` + ); + const data = await response.json(); + const newArtists: ArtistRecord[] = data.artists || []; + setArtists(newArtists); + } catch { + setArtists([]); + } finally { + setIsLoading(false); + } + }, [userData?.id, orgId]); + + useEffect(() => { + fetchArtists(); + }, [fetchArtists]); + + // Pre-fill from saved state on mount + useEffect(() => { + if (savedPriorityArtists && savedPriorityArtists.length > 0) { + setSelectedArtists(savedPriorityArtists); + } + }, [savedPriorityArtists]); + + const handleToggleArtist = (artistId: string) => { + setSelectedArtists((prev) => { + if (prev.includes(artistId)) { + // Remove artist + return prev.filter((id) => id !== artistId); + } else if (prev.length < MAX_ARTISTS) { + // Add artist (max 3) + return [...prev, artistId]; + } + return prev; + }); + }; + + const handleContinue = () => { + if (selectedArtists.length > 0) { + setPriorityArtists(selectedArtists); + onNext(); + } + }; + + const isDisabled = selectedArtists.length === 0; + + // Get initials for avatar fallback + const getInitials = (name: string | null) => { + if (!name) return "?"; + const parts = name.trim().split(" "); + if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); + }; + + if (isLoading) { + return ( +
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if (artists.length === 0) { + return ( +
+

No Artists Found

+

+ {orgName} doesn't have any artists yet. +

+
+ + +
+
+ ); + } + + return ( +
+

+ Here's {orgName}'s roster. +

+

+ Who are your priority artists right now? +

+

+ (Pick 1-{MAX_ARTISTS} you're most focused on) +

+ +
+ {artists.map((artist) => { + const isSelected = selectedArtists.includes(artist.account_id); + const isAtMax = + selectedArtists.length >= MAX_ARTISTS && !isSelected; + + return ( + + ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/components/Onboarding/steps/CompleteStep.tsx b/components/Onboarding/steps/CompleteStep.tsx new file mode 100644 index 000000000..28473f536 --- /dev/null +++ b/components/Onboarding/steps/CompleteStep.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useOnboarding, RecurringFrequency } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { Button } from "@/components/ui/button"; + +interface CompleteStepProps { + onNext: () => void; + onBack: () => void; +} + +const FREQUENCY_LABELS: Record, string> = { + weekly: "week", + biweekly: "2 weeks", + monthly: "month", +}; + +export default function CompleteStep({ onNext, onBack }: CompleteStepProps) { + void onNext; + void onBack; + + const { recurring, selectedTask, priorityArtists, complete } = + useOnboarding(); + const { userData } = useUserProvider(); + const { artists, setSelectedArtist } = useArtistProvider(); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleGoToDashboard = async () => { + if (!userData?.account_id) { + router.push("/"); + return; + } + + setIsSubmitting(true); + try { + await complete(userData.account_id); + + // Auto-select the first priority artist after onboarding + if (priorityArtists.length > 0) { + const firstPriorityArtistId = priorityArtists[0]; + const artistToSelect = artists.find( + (a) => a.account_id === firstPriorityArtistId + ); + if (artistToSelect) { + setSelectedArtist(artistToSelect); + } + } + + router.push("/"); + } catch { + router.push("/"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Celebration icon */} +
+
+ + 🎉 + +
+
+ +

You're all set!

+ + {recurring ? ( +

+ Your {selectedTask?.title} is + scheduled for every {FREQUENCY_LABELS[recurring]}. +

+ ) : ( +

+ Your first report is on its way. Set up recurring tasks anytime from + the Tasks page. +

+ )} + + +
+ ); +} diff --git a/components/Onboarding/steps/RecurringStep.tsx b/components/Onboarding/steps/RecurringStep.tsx new file mode 100644 index 000000000..b67e655fa --- /dev/null +++ b/components/Onboarding/steps/RecurringStep.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useOnboarding, RecurringFrequency } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import { usePrivy } from "@privy-io/react-auth"; +import { Button } from "@/components/ui/button"; +import { TASKS_API_URL, NEW_API_BASE_URL } from "@/lib/consts"; +import { interpolatePrompt } from "@/lib/onboarding"; +import { ArtistRecord } from "@/types/Artist"; + +interface RecurringStepProps { + onNext: () => void; + onBack: () => void; +} + +interface FrequencyOption { + value: RecurringFrequency; + label: string; + description: string; + cron: string | null; +} + +const FREQUENCY_OPTIONS: FrequencyOption[] = [ + { + value: "weekly", + label: "Every week", + description: "Get a report every Monday", + cron: "0 9 * * 1", // Every Monday at 9 AM + }, + { + value: "biweekly", + label: "Every 2 weeks", + description: "Get a report every other Monday", + cron: "0 9 1-7,15-21 * 1", // Approximate biweekly on Mondays + }, + { + value: "monthly", + label: "Monthly", + description: "Get a report on the 1st of each month", + cron: "0 9 1 * *", // 1st of every month at 9 AM + }, + { + value: null, + label: "No thanks", + description: "I'll set this up later", + cron: null, + }, +]; + +export default function RecurringStep({ onNext, onBack }: RecurringStepProps) { + const { setRecurring, recurring: savedRecurring, selectedTask, priorityArtists } = + useOnboarding(); + const { userData, email } = useUserProvider(); + const { getAccessToken } = usePrivy(); + + const [selectedFrequency, setSelectedFrequency] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [artistName, setArtistName] = useState("Artist"); + + // Pre-fill from saved state on mount + useEffect(() => { + if (savedRecurring !== undefined) { + setSelectedFrequency(savedRecurring); + } + }, [savedRecurring]); + + // Fetch the first priority artist's name + const fetchArtistName = useCallback(async () => { + if (!priorityArtists || priorityArtists.length === 0 || !userData?.id) { + return; + } + + try { + const artistId = priorityArtists[0]; + const params = new URLSearchParams({ + accountId: userData.id as string, + }); + + const response = await fetch( + `${NEW_API_BASE_URL}/api/artists?${params.toString()}` + ); + const data = await response.json(); + const artists: ArtistRecord[] = data.artists || []; + const artist = artists.find((a) => a.account_id === artistId); + if (artist?.name) { + setArtistName(artist.name); + } + } catch { + // Keep default "Artist" if fetch fails + } + }, [priorityArtists, userData?.id]); + + useEffect(() => { + fetchArtistName(); + }, [fetchArtistName]); + + const handleFinish = async () => { + setIsSubmitting(true); + + try { + // If recurring is selected and we have all required data, create the task + if ( + selectedFrequency && + selectedTask && + priorityArtists && + priorityArtists.length > 0 && + userData?.account_id + ) { + const userEmail = email || ""; + const frequencyOption = FREQUENCY_OPTIONS.find( + (opt) => opt.value === selectedFrequency + ); + + if (frequencyOption?.cron && userEmail) { + const accessToken = await getAccessToken(); + + if (accessToken) { + // Interpolate the prompt with artist name and user email + const interpolatedPrompt = interpolatePrompt( + selectedTask.prompt, + artistName, + userEmail + ); + + // Create the recurring task via the tasks API + const taskPayload = { + title: selectedTask.title, + prompt: interpolatedPrompt, + schedule: frequencyOption.cron, + artist_account_id: priorityArtists[0], + }; + + await fetch(TASKS_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(taskPayload), + }); + } + } + } + + // Update the onboarding state with the selected frequency + setRecurring(selectedFrequency); + onNext(); + } catch { + // Even if task creation fails, proceed with onboarding + setRecurring(selectedFrequency); + onNext(); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

+ Want me to do this automatically? +

+

+ Set up recurring automation to keep receiving insights. +

+ +
+ {FREQUENCY_OPTIONS.map((option) => { + const isSelected = selectedFrequency === option.value; + return ( + + ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/components/Onboarding/steps/ResultStep.tsx b/components/Onboarding/steps/ResultStep.tsx new file mode 100644 index 000000000..90d4449af --- /dev/null +++ b/components/Onboarding/steps/ResultStep.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useOnboarding } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import { Button } from "@/components/ui/button"; + +interface ResultStepProps { + onNext: () => void; + onBack: () => void; +} + +export default function ResultStep({ onNext, onBack }: ResultStepProps) { + const { taskResult } = useOnboarding(); + const { email } = useUserProvider(); + + return ( +
+ {/* Success icon */} +
+
+ + + +
+
+ +

+ Done! Here's what I found: +

+ + {/* Result content */} +
+ {taskResult ? ( +
+ {taskResult} +
+ ) : ( +

+ No result available. The task may have been skipped. +

+ )} +
+ + {/* Email notification */} + {email && taskResult && ( +

+ I just sent this to {email}. +

+ )} + + {/* Navigation buttons */} +
+ + +
+
+ ); +} diff --git a/components/Onboarding/steps/RoleStep.tsx b/components/Onboarding/steps/RoleStep.tsx new file mode 100644 index 000000000..b51f43815 --- /dev/null +++ b/components/Onboarding/steps/RoleStep.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useOnboarding, UserRole } from "@/hooks/useOnboarding"; +import { Button } from "@/components/ui/button"; + +interface RoleStepProps { + onNext: () => void; + onBack: () => void; +} + +interface RoleOption { + value: UserRole; + label: string; + description: string; +} + +const ROLE_OPTIONS: RoleOption[] = [ + { + value: "manager", + label: "Manager", + description: "Artist management and day-to-day operations", + }, + { + value: "label", + label: "Label / A&R", + description: "Label operations and artist development", + }, + { + value: "marketing", + label: "Marketing", + description: "Campaigns, social media, and growth", + }, + { + value: "pr", + label: "PR / Publicist", + description: "Press relations and media outreach", + }, + { + value: "artist", + label: "Artist", + description: "Creating and releasing music", + }, + { + value: "other", + label: "Other", + description: "Another role in the music industry", + }, +]; + +export default function RoleStep({ onNext, onBack }: RoleStepProps) { + const { setRole, role: savedRole, name } = useOnboarding(); + + // Local state for selected role + const [selectedRole, setSelectedRole] = useState(null); + + // Pre-fill from saved state on mount + useEffect(() => { + if (savedRole) { + setSelectedRole(savedRole); + } + }, [savedRole]); + + const handleContinue = () => { + if (selectedRole) { + setRole(selectedRole); + onNext(); + } + }; + + const isDisabled = !selectedRole; + + return ( +
+

+ Nice to meet you, {name || "there"}! +

+

+ What's your role? +

+ +
+ {ROLE_OPTIONS.map((option) => { + const isSelected = selectedRole === option.value; + return ( + + ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/components/Onboarding/steps/RunningStep.tsx b/components/Onboarding/steps/RunningStep.tsx new file mode 100644 index 000000000..0036d85b1 --- /dev/null +++ b/components/Onboarding/steps/RunningStep.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useOnboarding } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import { usePrivy } from "@privy-io/react-auth"; +import { Button } from "@/components/ui/button"; +import { runOnboardingTask } from "@/lib/onboarding"; +import { NEW_API_BASE_URL } from "@/lib/consts"; +import { ArtistRecord } from "@/types/Artist"; +import { AgentTemplateRow } from "@/types/AgentTemplates"; + +interface RunningStepProps { + onNext: () => void; + onBack: () => void; +} + +const PROGRESS_STEPS = [ + { label: "Researching", duration: 5000 }, + { label: "Analyzing", duration: 10000 }, + { label: "Sending email", duration: 15000 }, +]; + +const TIMEOUT_MS = 60000; + +export default function RunningStep({ onNext, onBack }: RunningStepProps) { + void onBack; // Not used during running state + + const { selectedTask, priorityArtists, setTaskResult, setTaskError, goToStep } = + useOnboarding(); + const { userData, email } = useUserProvider(); + const { getAccessToken } = usePrivy(); + + const [currentProgressStep, setCurrentProgressStep] = useState(0); + const [isRunning, setIsRunning] = useState(true); + const [error, setError] = useState(null); + const [showTimeout, setShowTimeout] = useState(false); + const [artistName, setArtistName] = useState("Artist"); + + const hasStartedRef = useRef(false); + const timeoutRef = useRef(null); + const progressTimeoutRef = useRef(null); + + // Fetch the first priority artist's name + const fetchArtistName = useCallback(async () => { + if (!priorityArtists || priorityArtists.length === 0 || !userData?.id) { + return; + } + + try { + const artistId = priorityArtists[0]; + const params = new URLSearchParams({ + accountId: userData.id as string, + }); + + const response = await fetch( + `${NEW_API_BASE_URL}/api/artists?${params.toString()}` + ); + const data = await response.json(); + const artists: ArtistRecord[] = data.artists || []; + const artist = artists.find((a) => a.account_id === artistId); + if (artist?.name) { + setArtistName(artist.name); + } + } catch { + // Keep default "Artist" if fetch fails + } + }, [priorityArtists, userData?.id]); + + // Animated progress step advancement + useEffect(() => { + if (!isRunning || error) return; + + const advanceProgress = () => { + if (currentProgressStep < PROGRESS_STEPS.length - 1) { + progressTimeoutRef.current = setTimeout(() => { + setCurrentProgressStep((prev) => Math.min(prev + 1, PROGRESS_STEPS.length - 1)); + advanceProgress(); + }, PROGRESS_STEPS[currentProgressStep].duration); + } + }; + + advanceProgress(); + + return () => { + if (progressTimeoutRef.current) { + clearTimeout(progressTimeoutRef.current); + } + }; + }, [isRunning, error, currentProgressStep]); + + // Run the task + const runTask = useCallback(async () => { + if (!selectedTask || !priorityArtists || priorityArtists.length === 0) { + setError("Missing task or artist selection"); + setIsRunning(false); + return; + } + + const userEmail = email || ""; + if (!userEmail) { + setError("Unable to get user email"); + setIsRunning(false); + return; + } + + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + setError("Unable to authenticate. Please try again."); + setIsRunning(false); + return; + } + + // Convert selectedTask (AgentTemplate) to AgentTemplateRow for the API + const templateRow: AgentTemplateRow = { + id: selectedTask.id, + title: selectedTask.title, + description: selectedTask.description || "", + prompt: selectedTask.prompt, + tags: selectedTask.tags, + creator: null, + is_private: false, + created_at: null, + favorites_count: null, + updated_at: null, + }; + + const result = await runOnboardingTask({ + template: templateRow, + artistName, + artistId: priorityArtists[0], + userEmail, + accessToken, + }); + + if (result.success) { + setTaskResult(result.result); + setTaskError(null); + onNext(); + } else { + setError(result.error || "Task failed. Please try again."); + setTaskError(result.error || "Task failed"); + setIsRunning(false); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred"; + setError(errorMessage); + setTaskError(errorMessage); + setIsRunning(false); + } + }, [selectedTask, priorityArtists, email, getAccessToken, artistName, setTaskResult, setTaskError, onNext]); + + // Start the task on mount + useEffect(() => { + if (hasStartedRef.current) return; + hasStartedRef.current = true; + + // Fetch artist name first, then run task + const init = async () => { + await fetchArtistName(); + runTask(); + }; + + init(); + + // Set up timeout warning + timeoutRef.current = setTimeout(() => { + setShowTimeout(true); + }, TIMEOUT_MS); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [fetchArtistName, runTask]); + + const handleRetry = () => { + setError(null); + setIsRunning(true); + setCurrentProgressStep(0); + setShowTimeout(false); + hasStartedRef.current = false; + + // Trigger re-run + const init = async () => { + await fetchArtistName(); + runTask(); + }; + init(); + + // Reset timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setShowTimeout(true); + }, TIMEOUT_MS); + }; + + const handleSkip = () => { + setTaskResult(null); + goToStep("recurring"); + }; + + // Error state + if (error) { + return ( +
+
+
+ + + +
+

Something went wrong

+

{error}

+
+
+ + +
+
+ ); + } + + // Running state + return ( +
+

Running your first task...

+ + {/* Animated spinner */} +
+
+ + + + +
+
+ + {/* Progress steps */} +
+ {PROGRESS_STEPS.map((step, index) => { + const isActive = index === currentProgressStep; + const isCompleted = index < currentProgressStep; + + return ( +
+
+ {isCompleted ? ( + + + + ) : ( + index + 1 + )} +
+ + {step.label} + {isActive && "..."} + +
+ ); + })} +
+ + {/* Timeout warning */} + {showTimeout && ( +
+

+ Taking longer than expected... +

+ +
+ )} +
+ ); +} diff --git a/components/Onboarding/steps/TaskPickerStep.tsx b/components/Onboarding/steps/TaskPickerStep.tsx new file mode 100644 index 000000000..0ebfa4a38 --- /dev/null +++ b/components/Onboarding/steps/TaskPickerStep.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useOnboarding, AgentTemplate } from "@/hooks/useOnboarding"; +import { filterTemplatesByRole } from "@/lib/onboarding/filterTemplatesByRole"; +import { Button } from "@/components/ui/button"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +interface TaskPickerStepProps { + onNext: () => void; + onBack: () => void; +} + +export default function TaskPickerStep({ onNext, onBack }: TaskPickerStepProps) { + const { setSelectedTask, selectedTask: savedTask, role } = useOnboarding(); + + const [templates, setTemplates] = useState([]); + const [filteredTemplates, setFilteredTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch templates on mount + useEffect(() => { + async function loadTemplates() { + try { + setIsLoading(true); + setError(null); + + const response = await fetch("/api/onboarding-templates"); + if (!response.ok) { + throw new Error("Failed to fetch templates"); + } + + const data: AgentTemplateRow[] = await response.json(); + setTemplates(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load tasks"); + } finally { + setIsLoading(false); + } + } + + loadTemplates(); + }, []); + + // Filter templates by role when templates or role changes + useEffect(() => { + if (templates.length > 0) { + const filtered = filterTemplatesByRole(templates, role); + setFilteredTemplates(filtered); + } + }, [templates, role]); + + // Pre-fill from saved state on mount + useEffect(() => { + if (savedTask && filteredTemplates.length > 0) { + const matchingTemplate = filteredTemplates.find((t) => t.id === savedTask.id); + if (matchingTemplate) { + setSelectedTemplate(matchingTemplate); + } + } + }, [savedTask, filteredTemplates]); + + const handleContinue = () => { + if (selectedTemplate) { + // Convert to AgentTemplate format for the onboarding state + const task: AgentTemplate = { + id: selectedTemplate.id, + title: selectedTemplate.title, + description: selectedTemplate.description, + prompt: selectedTemplate.prompt, + tags: selectedTemplate.tags, + }; + setSelectedTask(task); + onNext(); + } + }; + + const isDisabled = !selectedTemplate; + + // Loading skeleton + if (isLoading) { + return ( +
+

+ What would be most valuable for you right now? +

+

+ Loading available tasks... +

+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + // Error state + if (error) { + return ( +
+

+ Something went wrong +

+

{error}

+
+ + +
+
+ ); + } + + // Empty state + if (filteredTemplates.length === 0) { + return ( +
+

+ No tasks available +

+

+ We couldn't find any tasks matching your role. Let's continue with the setup. +

+
+ + +
+
+ ); + } + + return ( +
+

+ What would be most valuable for you right now? +

+

+ Pick a task and I'll run it for you right now. +

+ +
+ {filteredTemplates.map((template) => { + const isSelected = selectedTemplate?.id === template.id; + return ( + + ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/components/Onboarding/steps/WelcomeStep.tsx b/components/Onboarding/steps/WelcomeStep.tsx new file mode 100644 index 000000000..3a498a002 --- /dev/null +++ b/components/Onboarding/steps/WelcomeStep.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useOnboarding } from "@/hooks/useOnboarding"; +import { useUserProvider } from "@/providers/UserProvder"; +import useAccountOrganizations from "@/hooks/useAccountOrganizations"; +import { useOrganization } from "@/providers/OrganizationProvider"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface WelcomeStepProps { + onNext: () => void; + onBack: () => void; +} + +export default function WelcomeStep({ onNext, onBack }: WelcomeStepProps) { + void onBack; // Unused in welcome step (first step) + + const { setName, name: savedName } = useOnboarding(); + const { userData } = useUserProvider(); + const { data: organizations } = useAccountOrganizations(); + const { selectedOrgId } = useOrganization(); + + // Local state for the input field + const [nameInput, setNameInput] = useState(""); + + // Get the org name from selected org or first org + const selectedOrg = organizations?.find( + (org) => org.organization_id === selectedOrgId + ); + const orgName = + selectedOrg?.organization_name || + organizations?.[0]?.organization_name || + "your organization"; + + // Pre-fill name from onboarding state or user data on mount + useEffect(() => { + if (savedName) { + setNameInput(savedName); + } else if (userData?.name) { + setNameInput(userData.name); + } + }, [savedName, userData?.name]); + + const handleContinue = () => { + if (nameInput.trim()) { + setName(nameInput.trim()); + onNext(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && nameInput.trim()) { + handleContinue(); + } + }; + + const isDisabled = !nameInput.trim(); + + return ( +
+

Welcome to Recoup!

+

+ I'm your AI assistant for {orgName}. +

+ +
+ + setNameInput(e.target.value)} + onKeyDown={handleKeyDown} + className="h-12 text-base" + autoFocus + /> +
+ + +
+ ); +} diff --git a/components/Onboarding/steps/index.tsx b/components/Onboarding/steps/index.tsx new file mode 100644 index 000000000..a4f891e0b --- /dev/null +++ b/components/Onboarding/steps/index.tsx @@ -0,0 +1,8 @@ +export { default as WelcomeStep } from "./WelcomeStep"; +export { default as RoleStep } from "./RoleStep"; +export { default as ArtistsStep } from "./ArtistsStep"; +export { default as TaskPickerStep } from "./TaskPickerStep"; +export { default as RunningStep } from "./RunningStep"; +export { default as ResultStep } from "./ResultStep"; +export { default as RecurringStep } from "./RecurringStep"; +export { default as CompleteStep } from "./CompleteStep"; diff --git a/hooks/useOnboarding.tsx b/hooks/useOnboarding.tsx new file mode 100644 index 000000000..6c9816b4b --- /dev/null +++ b/hooks/useOnboarding.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useLocalStorage } from "usehooks-ts"; + +export type OnboardingStep = + | "welcome" + | "role" + | "artists" + | "task-picker" + | "running" + | "result" + | "recurring" + | "complete"; + +export type UserRole = + | "manager" + | "label" + | "marketing" + | "pr" + | "artist" + | "other" + | null; + +export type RecurringFrequency = "weekly" | "biweekly" | "monthly" | null; + +export interface AgentTemplate { + id: string; + title: string; + description: string | null; + prompt: string; + tags: string[] | null; +} + +export interface OnboardingState { + step: OnboardingStep; + name: string; + role: UserRole; + priorityArtists: string[]; + selectedTask: AgentTemplate | null; + taskResult: string | null; + taskError: string | null; + recurring: RecurringFrequency; +} + +const STORAGE_KEY = "recoup-onboarding"; + +const STEPS_ORDER: OnboardingStep[] = [ + "welcome", + "role", + "artists", + "task-picker", + "running", + "result", + "recurring", + "complete", +]; + +const initialState: OnboardingState = { + step: "welcome", + name: "", + role: null, + priorityArtists: [], + selectedTask: null, + taskResult: null, + taskError: null, + recurring: null, +}; + +interface UseOnboardingReturn { + state: OnboardingState; + step: OnboardingStep; + name: string; + role: UserRole; + priorityArtists: string[]; + selectedTask: AgentTemplate | null; + taskResult: string | null; + taskError: string | null; + recurring: RecurringFrequency; + setName: (name: string) => void; + setRole: (role: UserRole) => void; + setPriorityArtists: (artists: string[]) => void; + setSelectedTask: (task: AgentTemplate | null) => void; + setTaskResult: (result: string | null) => void; + setTaskError: (error: string | null) => void; + setRecurring: (frequency: RecurringFrequency) => void; + nextStep: () => void; + prevStep: () => void; + goToStep: (step: OnboardingStep) => void; + complete: (accountId: string) => Promise; + reset: () => void; + isFirstStep: boolean; + isLastStep: boolean; + currentStepIndex: number; + totalSteps: number; +} + +export function useOnboarding(): UseOnboardingReturn { + const [savedState, setSavedState] = useLocalStorage( + STORAGE_KEY, + null + ); + const [state, setState] = useState( + savedState ?? initialState + ); + const [isHydrated, setIsHydrated] = useState(false); + + // Hydrate from localStorage on mount (client-side only) + useEffect(() => { + if (savedState && !isHydrated) { + setState(savedState); + } + setIsHydrated(true); + }, [savedState, isHydrated]); + + // Persist to localStorage whenever state changes (after hydration) + useEffect(() => { + if (isHydrated) { + setSavedState(state); + } + }, [state, isHydrated, setSavedState]); + + const updateState = useCallback( + (updates: Partial) => { + setState((prev) => ({ ...prev, ...updates })); + }, + [] + ); + + const setName = useCallback( + (name: string) => updateState({ name }), + [updateState] + ); + + const setRole = useCallback( + (role: UserRole) => updateState({ role }), + [updateState] + ); + + const setPriorityArtists = useCallback( + (priorityArtists: string[]) => updateState({ priorityArtists }), + [updateState] + ); + + const setSelectedTask = useCallback( + (selectedTask: AgentTemplate | null) => updateState({ selectedTask }), + [updateState] + ); + + const setTaskResult = useCallback( + (taskResult: string | null) => updateState({ taskResult }), + [updateState] + ); + + const setTaskError = useCallback( + (taskError: string | null) => updateState({ taskError }), + [updateState] + ); + + const setRecurring = useCallback( + (recurring: RecurringFrequency) => updateState({ recurring }), + [updateState] + ); + + const currentStepIndex = STEPS_ORDER.indexOf(state.step); + + const nextStep = useCallback(() => { + const nextIndex = currentStepIndex + 1; + if (nextIndex < STEPS_ORDER.length) { + updateState({ step: STEPS_ORDER[nextIndex] }); + } + }, [currentStepIndex, updateState]); + + const prevStep = useCallback(() => { + const prevIndex = currentStepIndex - 1; + if (prevIndex >= 0) { + updateState({ step: STEPS_ORDER[prevIndex] }); + } + }, [currentStepIndex, updateState]); + + const goToStep = useCallback( + (step: OnboardingStep) => { + updateState({ step }); + }, + [updateState] + ); + + const reset = useCallback(() => { + setState(initialState); + setSavedState(null); + }, [setSavedState]); + + const complete = useCallback( + async (accountId: string): Promise => { + try { + const onboardingData = { + name: state.name, + role: state.role, + priorityArtists: state.priorityArtists, + selectedTask: state.selectedTask + ? { + id: state.selectedTask.id, + title: state.selectedTask.title, + } + : null, + recurring: state.recurring, + }; + + const onboardingStatus = { + completed: true, + completedAt: new Date().toISOString(), + }; + + const response = await fetch("/api/account/update", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountId, + onboarding_data: onboardingData, + onboarding_status: onboardingStatus, + }), + }); + + if (!response.ok) { + return false; + } + + // Clear localStorage on successful completion + setSavedState(null); + return true; + } catch { + return false; + } + }, + [state, setSavedState] + ); + + return { + state, + step: state.step, + name: state.name, + role: state.role, + priorityArtists: state.priorityArtists, + selectedTask: state.selectedTask, + taskResult: state.taskResult, + taskError: state.taskError, + recurring: state.recurring, + setName, + setRole, + setPriorityArtists, + setSelectedTask, + setTaskResult, + setTaskError, + setRecurring, + nextStep, + prevStep, + goToStep, + complete, + reset, + isFirstStep: currentStepIndex === 0, + isLastStep: currentStepIndex === STEPS_ORDER.length - 1, + currentStepIndex, + totalSteps: STEPS_ORDER.length, + }; +} + +export default useOnboarding; diff --git a/lib/onboarding/fetchOnboardingTemplates.ts b/lib/onboarding/fetchOnboardingTemplates.ts new file mode 100644 index 000000000..34df4a081 --- /dev/null +++ b/lib/onboarding/fetchOnboardingTemplates.ts @@ -0,0 +1,25 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +/** + * Fetches all onboarding templates from the agent_templates table. + * Onboarding templates are identified by having 'onboarding' in their tags array. + * These are system templates (creator = NULL) available to all users. + */ +export async function fetchOnboardingTemplates(): Promise { + const { data, error } = await supabase + .from("agent_templates") + .select("id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at") + .contains("tags", ["onboarding"]) + .eq("is_private", false) + .is("creator", null) + .order("title"); + + if (error) { + throw error; + } + + return data || []; +} + +export default fetchOnboardingTemplates; diff --git a/lib/onboarding/filterTemplatesByRole.ts b/lib/onboarding/filterTemplatesByRole.ts new file mode 100644 index 000000000..a3fcc49f2 --- /dev/null +++ b/lib/onboarding/filterTemplatesByRole.ts @@ -0,0 +1,30 @@ +import type { AgentTemplateRow } from "@/types/AgentTemplates"; +import type { UserRole } from "@/hooks/useOnboarding"; + +/** + * Filters onboarding templates by the user's role. + * Templates are tagged with role:manager, role:artist, role:label, etc. + * This function returns templates that match the user's role tag. + * + * If role is null or 'other', returns all templates (no role filtering). + */ +export function filterTemplatesByRole( + templates: AgentTemplateRow[], + role: UserRole +): AgentTemplateRow[] { + // If no role or 'other', return all templates + if (!role || role === "other") { + return templates; + } + + const roleTag = `role:${role}`; + + return templates.filter((template) => { + if (!template.tags) { + return false; + } + return template.tags.includes(roleTag); + }); +} + +export default filterTemplatesByRole; diff --git a/lib/onboarding/index.ts b/lib/onboarding/index.ts new file mode 100644 index 000000000..32ca2fe2b --- /dev/null +++ b/lib/onboarding/index.ts @@ -0,0 +1,13 @@ +export { fetchOnboardingTemplates } from "./fetchOnboardingTemplates"; +export { filterTemplatesByRole } from "./filterTemplatesByRole"; +export { interpolatePrompt } from "./interpolatePrompt"; +export { needsOnboarding } from "./needsOnboarding"; +export { runOnboardingTask } from "./runOnboardingTask"; +export type { + NeedsOnboardingParams, + OnboardingStatus, +} from "./needsOnboarding"; +export type { + RunOnboardingTaskParams, + RunOnboardingTaskResult, +} from "./runOnboardingTask"; diff --git a/lib/onboarding/interpolatePrompt.ts b/lib/onboarding/interpolatePrompt.ts new file mode 100644 index 000000000..df29045c6 --- /dev/null +++ b/lib/onboarding/interpolatePrompt.ts @@ -0,0 +1,15 @@ +/** + * Interpolates placeholders in an onboarding template prompt. + * Replaces {artistName} and {userEmail} with actual values. + */ +export function interpolatePrompt( + prompt: string, + artistName: string, + userEmail: string +): string { + return prompt + .replace(/\{artistName\}/g, artistName) + .replace(/\{userEmail\}/g, userEmail); +} + +export default interpolatePrompt; diff --git a/lib/onboarding/needsOnboarding.ts b/lib/onboarding/needsOnboarding.ts new file mode 100644 index 000000000..cccf981cf --- /dev/null +++ b/lib/onboarding/needsOnboarding.ts @@ -0,0 +1,57 @@ +import { AccountOrganization } from "@/hooks/useAccountOrganizations"; + +/** + * OnboardingStatus structure stored in account_info.onboarding_status + */ +export interface OnboardingStatus { + completed?: boolean; + completedAt?: string; +} + +/** + * Parameters for needsOnboarding check + */ +export interface NeedsOnboardingParams { + /** The user's onboarding_status from account_info */ + onboardingStatus: OnboardingStatus | null | undefined; + /** List of organizations the user belongs to */ + organizations: AccountOrganization[] | undefined; + /** Whether the org has artists (checked separately) */ + orgHasArtists: boolean; +} + +/** + * Determines if a user needs to go through onboarding. + * + * A user needs onboarding if ALL of the following are true: + * 1. User has at least one organization + * 2. onboarding_status.completed is not true + * 3. The organization has at least one artist + * + * @param params - The parameters to check + * @returns true if user should be redirected to onboarding + */ +export function needsOnboarding(params: NeedsOnboardingParams): boolean { + const { onboardingStatus, organizations, orgHasArtists } = params; + + // Check 1: User must have at least one organization + const hasOrg = organizations && organizations.length > 0; + if (!hasOrg) { + return false; + } + + // Check 2: Onboarding must not be completed + const isCompleted = onboardingStatus?.completed === true; + if (isCompleted) { + return false; + } + + // Check 3: Org must have at least one artist + if (!orgHasArtists) { + return false; + } + + return true; +} + +export default needsOnboarding; diff --git a/lib/onboarding/runOnboardingTask.ts b/lib/onboarding/runOnboardingTask.ts new file mode 100644 index 000000000..2b86c1c94 --- /dev/null +++ b/lib/onboarding/runOnboardingTask.ts @@ -0,0 +1,77 @@ +import type { AgentTemplateRow } from "@/types/AgentTemplates"; +import { interpolatePrompt } from "./interpolatePrompt"; + +export type RunOnboardingTaskParams = { + template: AgentTemplateRow; + artistName: string; + artistId: string; + userEmail: string; + accessToken: string; +}; + +export type RunOnboardingTaskResult = { + success: boolean; + result: string; + error?: string; +}; + +/** + * Executes an onboarding task by calling the chat/generate API. + * + * Takes a template from agent_templates, interpolates the prompt with + * artist name and user email, then calls the chat API to execute the task. + * The AI will use available MCP tools (like send_email) to complete the task. + */ +export async function runOnboardingTask({ + template, + artistName, + artistId, + userEmail, + accessToken, +}: RunOnboardingTaskParams): Promise { + try { + const interpolatedPrompt = interpolatePrompt( + template.prompt, + artistName, + userEmail + ); + + const response = await fetch("/api/chat/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + prompt: interpolatedPrompt, + artistId, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + result: "", + error: `API error: ${response.status} - ${errorText}`, + }; + } + + const data = await response.json(); + + return { + success: true, + result: data.text || "", + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + success: false, + result: "", + error: errorMessage, + }; + } +} + +export default runOnboardingTask; diff --git a/providers/Providers.tsx b/providers/Providers.tsx index daf9e895a..7e9c0a989 100644 --- a/providers/Providers.tsx +++ b/providers/Providers.tsx @@ -13,14 +13,15 @@ import WagmiProvider from "./WagmiProvider"; import { MiniAppProvider } from "./MiniAppProvider"; import { ThemeProvider } from "./ThemeProvider"; import { OrganizationProvider } from "./OrganizationProvider"; +import OnboardingGuard from "@/components/Onboarding/OnboardingGuard"; const queryClient = new QueryClient(); const Providers = ({ children }: { children: React.ReactNode }) => ( - @@ -30,15 +31,17 @@ const Providers = ({ children }: { children: React.ReactNode }) => ( - - - - - {children} - - - - + + + + + + {children} + + + + +