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 (
+
+ );
+}
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}
+
+
+
+
+