-
Onboarding
+
+ {/* Progress indicator - rendered above step content */}
+
+
+
+
+ {/* Current step content */}
+
+
+
);
}
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 (
+
+ );
+ })}
+
+
+ );
+}
From f4f189ae9bf26b2175a2343fa705ad960e5e9993 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:16:57 -0500
Subject: [PATCH 04/14] feat: [US-005] - Create WelcomeStep component
- Implement WelcomeStep with name input and org greeting
- Shows "Welcome to Recoup! I'm your AI assistant for [Org Name]"
- Shows "What should I call you?" with name input field
- Pre-fills name from onboarding state or user data
- Continue button disabled until name is entered
- Uses useAccountOrganizations and useOrganization for org name
- Adds placeholder step components (to be implemented in subsequent stories)
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/ArtistsStep.tsx | 29 ++++++
components/Onboarding/steps/CompleteStep.tsx | 22 +++++
components/Onboarding/steps/RecurringStep.tsx | 29 ++++++
components/Onboarding/steps/ResultStep.tsx | 29 ++++++
components/Onboarding/steps/RoleStep.tsx | 29 ++++++
components/Onboarding/steps/RunningStep.tsx | 22 +++++
.../Onboarding/steps/TaskPickerStep.tsx | 29 ++++++
components/Onboarding/steps/WelcomeStep.tsx | 98 +++++++++++++++++++
components/Onboarding/steps/index.tsx | 8 ++
9 files changed, 295 insertions(+)
create mode 100644 components/Onboarding/steps/ArtistsStep.tsx
create mode 100644 components/Onboarding/steps/CompleteStep.tsx
create mode 100644 components/Onboarding/steps/RecurringStep.tsx
create mode 100644 components/Onboarding/steps/ResultStep.tsx
create mode 100644 components/Onboarding/steps/RoleStep.tsx
create mode 100644 components/Onboarding/steps/RunningStep.tsx
create mode 100644 components/Onboarding/steps/TaskPickerStep.tsx
create mode 100644 components/Onboarding/steps/WelcomeStep.tsx
create mode 100644 components/Onboarding/steps/index.tsx
diff --git a/components/Onboarding/steps/ArtistsStep.tsx b/components/Onboarding/steps/ArtistsStep.tsx
new file mode 100644
index 000000000..7f2111bad
--- /dev/null
+++ b/components/Onboarding/steps/ArtistsStep.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+interface ArtistsStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function ArtistsStep({ onNext, onBack }: ArtistsStepProps) {
+ return (
+
+
Artists Step
+
Placeholder for Artists Step
+
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/steps/CompleteStep.tsx b/components/Onboarding/steps/CompleteStep.tsx
new file mode 100644
index 000000000..714b83728
--- /dev/null
+++ b/components/Onboarding/steps/CompleteStep.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+interface CompleteStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function CompleteStep({ onNext, onBack }: CompleteStepProps) {
+ void onBack; // Not typically used at completion
+ return (
+
+
Complete Step
+
Placeholder for Complete Step
+
+
+ );
+}
diff --git a/components/Onboarding/steps/RecurringStep.tsx b/components/Onboarding/steps/RecurringStep.tsx
new file mode 100644
index 000000000..bed513855
--- /dev/null
+++ b/components/Onboarding/steps/RecurringStep.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+interface RecurringStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function RecurringStep({ onNext, onBack }: RecurringStepProps) {
+ return (
+
+
Recurring Step
+
Placeholder for Recurring Step
+
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/steps/ResultStep.tsx b/components/Onboarding/steps/ResultStep.tsx
new file mode 100644
index 000000000..78e9b939c
--- /dev/null
+++ b/components/Onboarding/steps/ResultStep.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+interface ResultStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function ResultStep({ onNext, onBack }: ResultStepProps) {
+ return (
+
+
Result Step
+
Placeholder for Result Step
+
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/steps/RoleStep.tsx b/components/Onboarding/steps/RoleStep.tsx
new file mode 100644
index 000000000..a05c47b7a
--- /dev/null
+++ b/components/Onboarding/steps/RoleStep.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+interface RoleStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function RoleStep({ onNext, onBack }: RoleStepProps) {
+ return (
+
+
Role Step
+
Placeholder for Role Step
+
+
+
+
+
+ );
+}
diff --git a/components/Onboarding/steps/RunningStep.tsx b/components/Onboarding/steps/RunningStep.tsx
new file mode 100644
index 000000000..d979c6e9f
--- /dev/null
+++ b/components/Onboarding/steps/RunningStep.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+interface RunningStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function RunningStep({ onNext, onBack }: RunningStepProps) {
+ void onBack; // Not typically used during running
+ return (
+
+
Running Step
+
Placeholder for Running Step
+
+
+ );
+}
diff --git a/components/Onboarding/steps/TaskPickerStep.tsx b/components/Onboarding/steps/TaskPickerStep.tsx
new file mode 100644
index 000000000..23bd98c48
--- /dev/null
+++ b/components/Onboarding/steps/TaskPickerStep.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+interface TaskPickerStepProps {
+ onNext: () => void;
+ onBack: () => void;
+}
+
+export default function TaskPickerStep({ onNext, onBack }: TaskPickerStepProps) {
+ return (
+
+
Task Picker Step
+
Placeholder for Task Picker Step
+
+
+
+
+
+ );
+}
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";
From c845a2941651fa7c5e7c575a4c3ffd4fe87ac750 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:20:29 -0500
Subject: [PATCH 05/14] feat: [US-006] - Create RoleStep component
Implement RoleStep with:
- Personalized greeting using name from onboarding state
- 6 role cards in grid layout: Manager, Label/A&R, Marketing, PR/Publicist, Artist, Other
- Single-select with visual highlight on selected card
- Back and Continue buttons with brand primary color #345A5D
- Continue disabled until a role is selected
- Persists selection to localStorage via setRole() on continue
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/RoleStep.tsx | 123 ++++++++++++++++++++---
1 file changed, 111 insertions(+), 12 deletions(-)
diff --git a/components/Onboarding/steps/RoleStep.tsx b/components/Onboarding/steps/RoleStep.tsx
index a05c47b7a..b51f43815 100644
--- a/components/Onboarding/steps/RoleStep.tsx
+++ b/components/Onboarding/steps/RoleStep.tsx
@@ -1,28 +1,127 @@
"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 (
-
-
Role Step
-
Placeholder for Role Step
+
+
+ Nice to meet you, {name || "there"}!
+
+
+ What's your role?
+
+
+
+ {ROLE_OPTIONS.map((option) => {
+ const isSelected = selectedRole === option.value;
+ return (
+
+ );
+ })}
+
+
-
);
From 56e5e2e23a72bd9a65b98560910792d39b95d592 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:24:48 -0500
Subject: [PATCH 06/14] feat: [US-007] Create ArtistsStep component
- Shows org roster with artist selection (1-3 artists)
- Displays artist cards in grid with Avatar and Name
- Multi-select with visual highlight for selected artists
- Fetches artists via API with org ID context
- Pre-fills from saved onboarding state
- Handles loading and empty states gracefully
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/ArtistsStep.tsx | 214 ++++++++++++++++++--
1 file changed, 202 insertions(+), 12 deletions(-)
diff --git a/components/Onboarding/steps/ArtistsStep.tsx b/components/Onboarding/steps/ArtistsStep.tsx
index 7f2111bad..d7edc6793 100644
--- a/components/Onboarding/steps/ArtistsStep.tsx
+++ b/components/Onboarding/steps/ArtistsStep.tsx
@@ -1,28 +1,218 @@
"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.
+
+
+
+ Back
+
+
+ Skip
+
+
+
+ );
+ }
+
return (
-
-
Artists Step
-
Placeholder for Artists Step
+
+
+ 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 (
+
handleToggleArtist(artist.account_id)}
+ disabled={isAtMax}
+ className={`
+ p-4 rounded-xl border-2 text-center transition-all flex flex-col items-center gap-3
+ ${
+ isSelected
+ ? "border-[#345A5D] bg-[#345A5D]/10"
+ : isAtMax
+ ? "border-input opacity-50 cursor-not-allowed"
+ : "border-input hover:border-[#345A5D]/50"
+ }
+ `}
+ style={{
+ borderColor: isSelected ? "#345A5D" : undefined,
+ }}
+ >
+
+ {artist.image && (
+
+ )}
+
+ {getInitials(artist.name)}
+
+
+
+ {artist.name || "Unknown Artist"}
+
+
+ );
+ })}
+
+
-
+
Back
-
-
+
Continue
-
+
);
From b1841a56a65dad5ce2806e5c11f31e0410188606 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:30:37 -0500
Subject: [PATCH 07/14] feat: [US-008] add onboarding template helpers
- Add fetchOnboardingTemplates.ts to fetch templates with 'onboarding' tag
- Add filterTemplatesByRole.ts to filter by role:* tags
- Add interpolatePrompt.ts to replace {artistName} and {userEmail} placeholders
- Add barrel export in index.ts
Co-Authored-By: Claude Opus 4.5
---
lib/onboarding/fetchOnboardingTemplates.ts | 25 ++++++++++++++++++
lib/onboarding/filterTemplatesByRole.ts | 30 ++++++++++++++++++++++
lib/onboarding/index.ts | 3 +++
lib/onboarding/interpolatePrompt.ts | 15 +++++++++++
4 files changed, 73 insertions(+)
create mode 100644 lib/onboarding/fetchOnboardingTemplates.ts
create mode 100644 lib/onboarding/filterTemplatesByRole.ts
create mode 100644 lib/onboarding/index.ts
create mode 100644 lib/onboarding/interpolatePrompt.ts
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..a353f5fb1
--- /dev/null
+++ b/lib/onboarding/index.ts
@@ -0,0 +1,3 @@
+export { fetchOnboardingTemplates } from "./fetchOnboardingTemplates";
+export { filterTemplatesByRole } from "./filterTemplatesByRole";
+export { interpolatePrompt } from "./interpolatePrompt";
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;
From 711095d86fdffc5eaef311409066188af671eec5 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:36:44 -0500
Subject: [PATCH 08/14] feat: [US-009] Create runOnboardingTask helper function
Co-Authored-By: Claude Opus 4.5
---
lib/onboarding/index.ts | 5 ++
lib/onboarding/runOnboardingTask.ts | 77 +++++++++++++++++++++++++++++
2 files changed, 82 insertions(+)
create mode 100644 lib/onboarding/runOnboardingTask.ts
diff --git a/lib/onboarding/index.ts b/lib/onboarding/index.ts
index a353f5fb1..c337d5aa0 100644
--- a/lib/onboarding/index.ts
+++ b/lib/onboarding/index.ts
@@ -1,3 +1,8 @@
export { fetchOnboardingTemplates } from "./fetchOnboardingTemplates";
export { filterTemplatesByRole } from "./filterTemplatesByRole";
export { interpolatePrompt } from "./interpolatePrompt";
+export { runOnboardingTask } from "./runOnboardingTask";
+export type {
+ RunOnboardingTaskParams,
+ RunOnboardingTaskResult,
+} from "./runOnboardingTask";
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;
From 46eab86f8044cbccaf032ed4bbc324140df4eb78 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:40:16 -0500
Subject: [PATCH 09/14] feat: [US-010] Create TaskPickerStep component
- Add /api/onboarding-templates endpoint to fetch templates
- Implement full TaskPickerStep with template fetching via API
- Filter templates by user role using filterTemplatesByRole helper
- Single-select UI with visual highlight for selected task
- Includes loading skeleton, error state with retry, empty state with skip
- Convert AgentTemplateRow to AgentTemplate for onboarding state
Co-Authored-By: Claude Opus 4.5
---
app/api/onboarding-templates/route.ts | 24 ++
.../Onboarding/steps/TaskPickerStep.tsx | 209 +++++++++++++++++-
2 files changed, 221 insertions(+), 12 deletions(-)
create mode 100644 app/api/onboarding-templates/route.ts
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/components/Onboarding/steps/TaskPickerStep.tsx b/components/Onboarding/steps/TaskPickerStep.tsx
index 23bd98c48..0ebfa4a38 100644
--- a/components/Onboarding/steps/TaskPickerStep.tsx
+++ b/components/Onboarding/steps/TaskPickerStep.tsx
@@ -1,28 +1,213 @@
"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}
+
+
+ Back
+
+ window.location.reload()}
+ className="px-8"
+ style={{ backgroundColor: "#345A5D" }}
+ >
+ Try Again
+
+
+
+ );
+ }
+
+ // 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.
+
+
+
+ Back
+
+
+ Skip for now
+
+
+
+ );
+ }
+
return (
-
-
Task Picker Step
-
Placeholder for Task Picker Step
+
+
+ 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 (
+
setSelectedTemplate(template)}
+ className={`
+ w-full p-6 rounded-xl border-2 text-left transition-all
+ ${
+ isSelected
+ ? "border-[#345A5D] bg-[#345A5D]/10"
+ : "border-input hover:border-[#345A5D]/50"
+ }
+ `}
+ style={{
+ borderColor: isSelected ? "#345A5D" : undefined,
+ }}
+ >
+ {template.title}
+ {template.description && (
+
+ {template.description}
+
+ )}
+
+ );
+ })}
+
+
-
+
Back
-
-
+
Run it now
-
+
);
From 257a68b750cd52228007a4ddd58e506b1d2f1155 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:43:38 -0500
Subject: [PATCH 10/14] feat: [US-011] Create RunningStep component
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the RunningStep component that executes the user's first
automated task during onboarding. Features include:
- Animated spinner with brand color styling
- Animated progress steps (Researching → Analyzing → Sending email)
- Integration with runOnboardingTask helper and chat/generate API
- Error state with "Try again" and "Skip for now" buttons
- 60-second timeout warning with skip option
- Fetches first priority artist name for prompt interpolation
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/RunningStep.tsx | 330 +++++++++++++++++++-
1 file changed, 320 insertions(+), 10 deletions(-)
diff --git a/components/Onboarding/steps/RunningStep.tsx b/components/Onboarding/steps/RunningStep.tsx
index d979c6e9f..0036d85b1 100644
--- a/components/Onboarding/steps/RunningStep.tsx
+++ b/components/Onboarding/steps/RunningStep.tsx
@@ -1,22 +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 typically used during running
+ 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}
+
+
+
+ Skip for now
+
+
+ Try again
+
+
+
+ );
+ }
+
+ // Running state
return (
-
-
Running Step
-
Placeholder for Running Step
-
- Skip (for testing)
-
+
+
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...
+
+
+ Skip for now
+
+
+ )}
);
}
From a022a7cdfd63334084488b70fffb7173d2343ed5 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:45:17 -0500
Subject: [PATCH 11/14] feat: [US-012] Create ResultStep component
- Show task result from onboarding state in readable format
- Display success icon with brand color
- Show 'I just sent this to [email]' notification
- Back button allows retry, Continue progresses flow
- Handle null taskResult case for skipped tasks
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/ResultStep.tsx | 73 ++++++++++++++++++----
1 file changed, 62 insertions(+), 11 deletions(-)
diff --git a/components/Onboarding/steps/ResultStep.tsx b/components/Onboarding/steps/ResultStep.tsx
index 78e9b939c..90d4449af 100644
--- a/components/Onboarding/steps/ResultStep.tsx
+++ b/components/Onboarding/steps/ResultStep.tsx
@@ -1,28 +1,79 @@
"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 (
-
-
Result Step
-
Placeholder for Result Step
-
-
+ {/* 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 */}
+
+
Back
-
-
+
Continue
-
+
);
From 3ba3311063ed440443203f93375640b188a97101 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:49:25 -0500
Subject: [PATCH 12/14] feat: [US-013] Create RecurringStep component
Implement the recurring task setup step with:
- 4 frequency options: weekly, biweekly, monthly, none
- Radio button UI with visual selection state
- Task creation via TASKS_API_URL when frequency selected
- Interpolates prompt with artist name and user email
- Cron schedule configuration for each frequency
- Pre-fill from saved state for back navigation
- Loading state during task creation
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/RecurringStep.tsx | 232 +++++++++++++++++-
1 file changed, 219 insertions(+), 13 deletions(-)
diff --git a/components/Onboarding/steps/RecurringStep.tsx b/components/Onboarding/steps/RecurringStep.tsx
index bed513855..b67e655fa 100644
--- a/components/Onboarding/steps/RecurringStep.tsx
+++ b/components/Onboarding/steps/RecurringStep.tsx
@@ -1,28 +1,234 @@
"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 (
-
-
Recurring Step
-
Placeholder for Recurring Step
+
+
+ Want me to do this automatically?
+
+
+ Set up recurring automation to keep receiving insights.
+
+
+
+ {FREQUENCY_OPTIONS.map((option) => {
+ const isSelected = selectedFrequency === option.value;
+ return (
+
setSelectedFrequency(option.value)}
+ className={`
+ w-full p-4 rounded-xl border-2 text-left transition-all
+ ${
+ isSelected
+ ? "border-[#345A5D] bg-[#345A5D]/10"
+ : "border-input hover:border-[#345A5D]/50"
+ }
+ `}
+ style={{
+ borderColor: isSelected ? "#345A5D" : undefined,
+ }}
+ >
+
+
+ {isSelected && (
+
+ )}
+
+
+
+ {option.value === null ? "No thanks" : option.label}
+
+
+ {option.value === null
+ ? "I'll set this up later"
+ : option.description}
+
+
+
+
+ );
+ })}
+
+
-
+
Back
-
-
+
- Finish
-
+ {isSubmitting ? "Setting up..." : "Finish"}
+
);
From 6501f6085f7ac7523256122567fad4adcdeed141 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:52:56 -0500
Subject: [PATCH 13/14] feat: [US-014] Create CompleteStep component
Implement the final onboarding step with:
- Celebration emoji and "You're all set!" message
- Conditional messaging based on recurring frequency selection
- "Go to Dashboard" button that calls complete() to persist
onboarding data and redirects to home
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/steps/CompleteStep.tsx | 80 +++++++++++++++++---
1 file changed, 71 insertions(+), 9 deletions(-)
diff --git a/components/Onboarding/steps/CompleteStep.tsx b/components/Onboarding/steps/CompleteStep.tsx
index 714b83728..83398284a 100644
--- a/components/Onboarding/steps/CompleteStep.tsx
+++ b/components/Onboarding/steps/CompleteStep.tsx
@@ -1,22 +1,84 @@
"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useOnboarding, RecurringFrequency } from "@/hooks/useOnboarding";
+import { useUserProvider } from "@/providers/UserProvder";
+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 onBack; // Not typically used at completion
+ void onNext;
+ void onBack;
+
+ const { recurring, selectedTask, complete } = useOnboarding();
+ const { userData } = useUserProvider();
+ 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);
+ router.push("/");
+ } catch {
+ router.push("/");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
return (
-
-
Complete Step
-
Placeholder for Complete Step
-
+ {/* 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.
+
+ )}
+
+
- Go to Dashboard
-
+ {isSubmitting ? "Finishing up..." : "Go to Dashboard"}
+
);
}
From 6f02c39262a043a58a16649d7797e066275f2de7 Mon Sep 17 00:00:00 2001
From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com>
Date: Wed, 28 Jan 2026 15:59:47 -0500
Subject: [PATCH 14/14] feat: [US-016] Add onboarding detection and redirect
- Create needsOnboarding.ts helper function to check if user needs onboarding
- Create OnboardingGuard component to redirect new org users to /onboarding
- Add OnboardingGuard to Providers hierarchy inside ArtistProvider
- Auto-select first priority artist when completing onboarding
- Skip redirect if already on /onboarding route or already completed onboarding
Co-Authored-By: Claude Opus 4.5
---
components/Onboarding/OnboardingGuard.tsx | 84 ++++++++++++++++++++
components/Onboarding/steps/CompleteStep.tsx | 17 +++-
lib/onboarding/index.ts | 5 ++
lib/onboarding/needsOnboarding.ts | 57 +++++++++++++
providers/Providers.tsx | 27 ++++---
5 files changed, 177 insertions(+), 13 deletions(-)
create mode 100644 components/Onboarding/OnboardingGuard.tsx
create mode 100644 lib/onboarding/needsOnboarding.ts
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/steps/CompleteStep.tsx b/components/Onboarding/steps/CompleteStep.tsx
index 83398284a..28473f536 100644
--- a/components/Onboarding/steps/CompleteStep.tsx
+++ b/components/Onboarding/steps/CompleteStep.tsx
@@ -4,6 +4,7 @@ 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 {
@@ -21,8 +22,10 @@ export default function CompleteStep({ onNext, onBack }: CompleteStepProps) {
void onNext;
void onBack;
- const { recurring, selectedTask, complete } = useOnboarding();
+ const { recurring, selectedTask, priorityArtists, complete } =
+ useOnboarding();
const { userData } = useUserProvider();
+ const { artists, setSelectedArtist } = useArtistProvider();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -35,6 +38,18 @@ export default function CompleteStep({ onNext, onBack }: CompleteStepProps) {
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("/");
diff --git a/lib/onboarding/index.ts b/lib/onboarding/index.ts
index c337d5aa0..32ca2fe2b 100644
--- a/lib/onboarding/index.ts
+++ b/lib/onboarding/index.ts
@@ -1,7 +1,12 @@
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,
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/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}
+
+
+
+
+