diff --git a/components/Chat/ChatGreeting.tsx b/components/Chat/ChatGreeting.tsx index dbe43911d..e95c002f4 100644 --- a/components/Chat/ChatGreeting.tsx +++ b/components/Chat/ChatGreeting.tsx @@ -2,13 +2,17 @@ import { useArtistProvider } from "@/providers/ArtistProvider"; import ImageWithFallback from "@/components/ImageWithFallback"; import ValuationHero from "@/components/Home/ValuationHero"; import useHomeValuation from "@/hooks/useHomeValuation"; +import TasksModule from "@/components/Home/TasksModule"; import { VALUATION_URL } from "@/lib/consts"; /** - * The empty chat's artist header (recoupable/chat#1850): the artist-scoped - * catalog valuation hero when the account has measured data for the current - * scope, otherwise the "Ask me about" greeting plus a CTA into the valuation - * funnel. All context arrives via provider-backed hooks — no props beyond + * The empty chat's home header (recoupable/chat#1850), valuation first — + * it's the headline number customers arrive with from the marketing + * funnel: the artist-scoped catalog valuation hero when the account has + * measured data for the current scope, otherwise the "Ask me about" + * greeting plus a CTA into the valuation funnel; the "label at work" + * tasks module renders beneath it (self-hiding when it has nothing to + * show). All context arrives via provider-backed hooks — no props beyond * the fade flag the chat empty state already owned, so the chat component * needs no knowledge of what this header shows. */ @@ -24,13 +28,14 @@ export function ChatGreeting({ isVisible }: { isVisible: boolean }) { if (valuation.show) { return ( -
+
+
); } @@ -60,7 +65,7 @@ export function ChatGreeting({ isVisible }: { isVisible: boolean }) { )} {isArtistSelected ? artistName : "your roster"} -
+
+
+ +
); } diff --git a/components/Home/HomeRunRow.tsx b/components/Home/HomeRunRow.tsx new file mode 100644 index 000000000..3ecf555ed --- /dev/null +++ b/components/Home/HomeRunRow.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; +import { getTaskDisplayName } from "@/lib/tasks/getTaskDisplayName"; +import { getStatusColor } from "@/lib/tasks/getStatusColor"; +import { getStatusLabel } from "@/lib/tasks/getStatusLabel"; +import { formatTimestamp } from "@/lib/tasks/formatTimestamp"; + +/** + * Compact task-run row for the homepage tasks module. Links to the + * existing run detail page. Per-run sent-email subjects are not exposed + * by `GET /api/tasks/runs` yet, so this shows what is available: run + * name, status, and finished-at (recoupable/chat#1850). + */ +const HomeRunRow = ({ run }: { run: TaskRunItem }) => ( + +
+

+ {getTaskDisplayName(run.taskIdentifier)} +

+

+ {formatTimestamp(run.finishedAt ?? run.createdAt)} +

+
+ + {getStatusLabel(run.status)} + + +); + +export default HomeRunRow; diff --git a/components/Home/StarterTaskCard.tsx b/components/Home/StarterTaskCard.tsx new file mode 100644 index 000000000..5a6cfd5bc --- /dev/null +++ b/components/Home/StarterTaskCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useCreateStarterTask } from "@/hooks/useCreateStarterTask"; + +/** + * Empty-state suggestion for the homepage tasks module: one click + * schedules an existing /agents Report template for the selected artist, + * Mondays at 9am, via the existing task-creation path + * (recoupable/chat#1850). Hidden when no Report template exists. + */ +const StarterTaskCard = () => { + const { selectedArtist } = useArtistProvider(); + const { handleCreateStarterTask, isCreating, isScheduled, starterTemplate } = + useCreateStarterTask(); + const artistName = selectedArtist?.name || "your artist"; + + if (!starterTemplate) return null; + + if (isScheduled) { + return ( +

+ Scheduled: {starterTemplate.title} for {artistName}, Mondays at 9am. +

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

+ {starterTemplate.title} for {artistName}, Mondays +

+

+ {starterTemplate.description} +

+
+ +
+ ); +}; + +export default StarterTaskCard; diff --git a/components/Home/TasksModule.tsx b/components/Home/TasksModule.tsx new file mode 100644 index 000000000..f06a7eeec --- /dev/null +++ b/components/Home/TasksModule.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useTaskRuns } from "@/hooks/useTaskRuns"; +import { getHomeTasksModuleState } from "@/lib/home/getHomeTasksModuleState"; +import HomeRunRow from "./HomeRunRow"; +import StarterTaskCard from "./StarterTaskCard"; + +/** + * "Your label at work" homepage module: recent task runs (existing + * GET /api/tasks/runs) or the one-click starter suggestion for fresh + * accounts (recoupable/chat#1850). Renders nothing while loading or on + * failure so the homepage never blocks on it. + */ +const TasksModule = () => { + const { data: runs, isLoading, isError } = useTaskRuns(); + const { selectedArtist } = useArtistProvider(); + + const state = getHomeTasksModuleState({ + runs, + runsFailed: isError, + isLoading, + hasArtist: !!selectedArtist?.account_id, + }); + + if (state.view === "hidden") return null; + + return ( +
+
+

+ Your label at work +

+ + View all + +
+ {state.view === "runs" ? ( +
+ {state.runs.map((run) => ( + + ))} +
+ ) : ( + + )} +
+ ); +}; + +export default TasksModule; diff --git a/hooks/useCreateStarterTask.ts b/hooks/useCreateStarterTask.ts new file mode 100644 index 000000000..43fb29993 --- /dev/null +++ b/hooks/useCreateStarterTask.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import { createStarterTask } from "@/lib/home/createStarterTask"; +import { findStarterTemplate } from "@/lib/home/findStarterTemplate"; +import fetchAgentTemplates from "@/lib/agent-templates/fetchAgentTemplates"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useUserProvider } from "@/providers/UserProvder"; + +/** + * One-click creation of the homepage starter task. The task is sourced + * from an existing /agents template (DRY — findStarterTemplate picks it + * from the shared agent-templates query); auth + artist come from + * providers and the mutation goes through the existing `POST /api/tasks` + * path which also mints the schedule (recoupable/chat#1850). + */ +export function useCreateStarterTask() { + const { getAccessToken } = usePrivy(); + const { selectedArtist } = useArtistProvider(); + const { userData } = useUserProvider(); + const queryClient = useQueryClient(); + + const { data: templates } = useQuery({ + queryKey: ["agent-templates"], + queryFn: () => fetchAgentTemplates(userData!), + retry: 1, + enabled: !!userData?.id, + }); + const starterTemplate = findStarterTemplate(templates); + + const { + mutate: handleCreateStarterTask, + isPending: isCreating, + isSuccess: isScheduled, + } = useMutation({ + mutationFn: async () => { + const artistAccountId = selectedArtist?.account_id; + const artistName = selectedArtist?.name; + if (!artistAccountId || !artistName) { + throw new Error("ARTIST_REQUIRED"); + } + if (!starterTemplate) { + throw new Error("TEMPLATE_REQUIRED"); + } + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("AUTH_REQUIRED"); + } + return createStarterTask(accessToken, { + template: starterTemplate, + artistName, + artistAccountId, + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["scheduled-actions"], + exact: false, + }); + toast.success("Task scheduled for Mondays at 9am"); + }, + onError: (error) => { + if (error instanceof Error && error.message === "ARTIST_REQUIRED") { + toast.error("Please select an artist first."); + return; + } + if (error instanceof Error && error.message === "AUTH_REQUIRED") { + toast.error("Please sign in to create a task."); + return; + } + toast.error("Failed to create the task. Please try again."); + }, + }); + + return { handleCreateStarterTask, isCreating, isScheduled, starterTemplate }; +} diff --git a/lib/home/__tests__/buildStarterTaskParams.test.ts b/lib/home/__tests__/buildStarterTaskParams.test.ts new file mode 100644 index 000000000..1679a6ce0 --- /dev/null +++ b/lib/home/__tests__/buildStarterTaskParams.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { buildStarterTaskParams } from "@/lib/home/buildStarterTaskParams"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +const template = { + id: "9046c7e9-fd5a-4f08-a472-381d51bd6c90", + title: "Weekly Performance Dashboard", + description: "Weekly stats email", + prompt: + "Set up a weekly performance dashboard that emails me every Monday with my stats from all platforms.", + tags: ["Report"], + creator: null, + is_private: false, + created_at: null, + favorites_count: null, + updated_at: null, +} as AgentTemplateRow; + +describe("buildStarterTaskParams", () => { + const params = buildStarterTaskParams({ + template, + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }); + + it("titles the task after the template and the artist", () => { + expect(params.title).toBe("Weekly Performance Dashboard — Del Water Gap"); + }); + + it("uses the agent template's prompt verbatim (DRY — no hand-written prompt)", () => { + expect(params.prompt).toBe(template.prompt); + }); + + it("schedules Mondays", () => { + expect(params.schedule).toBe("0 9 * * 1"); + }); + + it("targets the artist account", () => { + expect(params.artist_account_id).toBe("artist-1"); + }); +}); diff --git a/lib/home/__tests__/createStarterTask.test.ts b/lib/home/__tests__/createStarterTask.test.ts new file mode 100644 index 000000000..98222be57 --- /dev/null +++ b/lib/home/__tests__/createStarterTask.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStarterTask } from "@/lib/home/createStarterTask"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { DEFAULT_MODEL } from "@/lib/consts"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(), +})); + +const starterTemplate = { + id: "9046c7e9-fd5a-4f08-a472-381d51bd6c90", + title: "Weekly Performance Dashboard", + description: "Weekly stats email", + prompt: + "Set up a weekly performance dashboard that emails me every Monday with my stats from all platforms.", + tags: ["Report"], + creator: null, + is_private: false, + created_at: null, + favorites_count: null, + updated_at: null, +} as import("@/types/AgentTemplates").AgentTemplateRow; + +describe("createStarterTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientApiBaseUrl).mockReturnValue( + "https://api.recoupable.com", + ); + }); + + it("POSTs the pre-wired weekly report task to /api/tasks", async () => { + const createdTask = { id: "task-1" }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + status: "success", + tasks: [createdTask], + }), + }) as unknown as typeof fetch; + + const result = await createStarterTask("test-token", { + template: starterTemplate, + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/tasks", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }, + }), + ); + const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(init.body)); + expect(body.title).toBe("Weekly Performance Dashboard — Del Water Gap"); + expect(body.schedule).toBe("0 9 * * 1"); + expect(body.artist_account_id).toBe("artist-1"); + expect(body.model).toBe(DEFAULT_MODEL); + expect(body.prompt).toBe(starterTemplate.prompt); + expect(result).toEqual(createdTask); + }); + + it("propagates API errors", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue("boom"), + }) as unknown as typeof fetch; + + await expect( + createStarterTask("test-token", { + template: starterTemplate, + artistName: "Del Water Gap", + artistAccountId: "artist-1", + }), + ).rejects.toThrow("HTTP 500"); + }); +}); diff --git a/lib/home/__tests__/findStarterTemplate.test.ts b/lib/home/__tests__/findStarterTemplate.test.ts new file mode 100644 index 000000000..36a2bc7bd --- /dev/null +++ b/lib/home/__tests__/findStarterTemplate.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { findStarterTemplate } from "@/lib/home/findStarterTemplate"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +const template = (over: Partial): AgentTemplateRow => + ({ + id: "t-1", + title: "Some Agent", + description: "", + prompt: "do things", + tags: null, + creator: null, + is_private: false, + created_at: null, + favorites_count: null, + updated_at: null, + ...over, + }) as AgentTemplateRow; + +describe("findStarterTemplate", () => { + it("prefers the Weekly Performance Dashboard template", () => { + const templates = [ + template({ id: "a", title: "Run Performance Report", tags: ["Report"] }), + template({ + id: "b", + title: "Weekly Performance Dashboard", + tags: ["Report"], + }), + ]; + expect(findStarterTemplate(templates)?.id).toBe("b"); + }); + + it("falls back to the first Report-tagged template", () => { + const templates = [ + template({ id: "a", title: "5 Viral Content Ideas", tags: ["Create"] }), + template({ id: "b", title: "YouTube Revenue Report", tags: ["Report"] }), + ]; + expect(findStarterTemplate(templates)?.id).toBe("b"); + }); + + it("returns undefined when no Report template exists", () => { + expect( + findStarterTemplate([template({ tags: ["Create"] })]), + ).toBeUndefined(); + expect(findStarterTemplate([])).toBeUndefined(); + expect(findStarterTemplate(undefined)).toBeUndefined(); + }); +}); diff --git a/lib/home/__tests__/getHomeTasksModuleState.test.ts b/lib/home/__tests__/getHomeTasksModuleState.test.ts new file mode 100644 index 000000000..d53da4093 --- /dev/null +++ b/lib/home/__tests__/getHomeTasksModuleState.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { getHomeTasksModuleState } from "@/lib/home/getHomeTasksModuleState"; +import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; + +const makeRun = (id: string): TaskRunItem => ({ + id, + status: "COMPLETED", + taskIdentifier: "customer-prompt-task", + createdAt: "2026-07-06T09:00:00Z", + startedAt: "2026-07-06T09:00:01Z", + finishedAt: "2026-07-06T09:05:00Z", + durationMs: 299000, +}); + +describe("getHomeTasksModuleState", () => { + it("hides the module while runs are loading", () => { + expect( + getHomeTasksModuleState({ + runs: undefined, + runsFailed: false, + isLoading: true, + hasArtist: true, + }), + ).toEqual({ view: "hidden" }); + }); + + it("hides the module when the runs request failed", () => { + expect( + getHomeTasksModuleState({ + runs: undefined, + runsFailed: true, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "hidden" }); + }); + + it("shows recent runs when the account has task history", () => { + const runs = ["a", "b", "c"].map(makeRun); + expect( + getHomeTasksModuleState({ + runs, + runsFailed: false, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "runs", runs }); + }); + + it("caps the module at five runs", () => { + const runs = ["a", "b", "c", "d", "e", "f", "g"].map(makeRun); + const state = getHomeTasksModuleState({ + runs, + runsFailed: false, + isLoading: false, + hasArtist: true, + }); + expect(state).toEqual({ view: "runs", runs: runs.slice(0, 5) }); + }); + + it("offers the starter task to a fresh account with an artist", () => { + expect( + getHomeTasksModuleState({ + runs: [], + runsFailed: false, + isLoading: false, + hasArtist: true, + }), + ).toEqual({ view: "starter" }); + }); + + it("hides the starter card when no artist is selected", () => { + expect( + getHomeTasksModuleState({ + runs: [], + runsFailed: false, + isLoading: false, + hasArtist: false, + }), + ).toEqual({ view: "hidden" }); + }); +}); diff --git a/lib/home/buildStarterTaskParams.ts b/lib/home/buildStarterTaskParams.ts new file mode 100644 index 000000000..55258f748 --- /dev/null +++ b/lib/home/buildStarterTaskParams.ts @@ -0,0 +1,27 @@ +import type { CreateTaskParams } from "@/lib/tasks/createTask"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +interface BuildStarterTaskParamsInput { + template: AgentTemplateRow; + artistName: string; + artistAccountId: string; +} + +/** + * The pre-wired one-click suggestion shown in the homepage tasks module's + * empty state (recoupable/chat#1850). The prompt is the chosen /agents + * template's prompt verbatim (DRY — see findStarterTemplate); only the + * title is personalized so the task list reads per-artist. + */ +export function buildStarterTaskParams({ + template, + artistName, + artistAccountId, +}: BuildStarterTaskParamsInput): Omit { + return { + title: `${template.title} — ${artistName}`, + prompt: template.prompt, + schedule: "0 9 * * 1", + artist_account_id: artistAccountId, + }; +} diff --git a/lib/home/createStarterTask.ts b/lib/home/createStarterTask.ts new file mode 100644 index 000000000..e2384bd05 --- /dev/null +++ b/lib/home/createStarterTask.ts @@ -0,0 +1,26 @@ +import { createTask } from "@/lib/tasks/createTask"; +import type { Task } from "@/lib/tasks/getTasks"; +import type { AgentTemplateRow } from "@/types/AgentTemplates"; +import { buildStarterTaskParams } from "@/lib/home/buildStarterTaskParams"; +import { DEFAULT_MODEL } from "@/lib/consts"; + +interface CreateStarterTaskInput { + template: AgentTemplateRow; + artistName: string; + artistAccountId: string; +} + +/** + * Creates the homepage starter task through the existing task-creation + * path (`POST /api/tasks`, which also mints the schedule). The task is + * built from an existing /agents template (DRY). + */ +export async function createStarterTask( + accessToken: string, + input: CreateStarterTaskInput, +): Promise { + return createTask(accessToken, { + ...buildStarterTaskParams(input), + model: DEFAULT_MODEL, + }); +} diff --git a/lib/home/findStarterTemplate.ts b/lib/home/findStarterTemplate.ts new file mode 100644 index 000000000..78c014f28 --- /dev/null +++ b/lib/home/findStarterTemplate.ts @@ -0,0 +1,20 @@ +import type { AgentTemplateRow } from "@/types/AgentTemplates"; + +const PREFERRED_TITLE = "Weekly Performance Dashboard"; + +/** + * Picks the agent template behind the homepage starter task (DRY — the + * task's prompt comes from the existing /agents templates, never a + * hand-written string; recoupable/chat#1850 review). Prefers the weekly + * dashboard template, falls back to any Report-tagged agent, and returns + * undefined (card hidden) when none exist. + */ +export function findStarterTemplate( + templates: AgentTemplateRow[] | undefined, +): AgentTemplateRow | undefined { + if (!templates?.length) return undefined; + return ( + templates.find((t) => t.title === PREFERRED_TITLE) ?? + templates.find((t) => (t.tags ?? []).includes("Report")) + ); +} diff --git a/lib/home/getHomeTasksModuleState.ts b/lib/home/getHomeTasksModuleState.ts new file mode 100644 index 000000000..b33004b25 --- /dev/null +++ b/lib/home/getHomeTasksModuleState.ts @@ -0,0 +1,35 @@ +import type { TaskRunItem } from "@/lib/tasks/getTaskRuns"; + +const MAX_HOME_RUNS = 5; + +interface GetHomeTasksModuleStateParams { + runs: TaskRunItem[] | undefined; + runsFailed: boolean; + isLoading: boolean; + hasArtist: boolean; +} + +export type HomeTasksModuleState = + | { view: "hidden" } + | { view: "runs"; runs: TaskRunItem[] } + | { view: "starter" }; + +/** + * Decides what the homepage tasks module renders: recent runs for accounts + * with task history, the one-click starter suggestion for fresh accounts + * with an artist, or nothing while loading / on failure so the homepage + * never blocks on this module (recoupable/chat#1850). + */ +export function getHomeTasksModuleState({ + runs, + runsFailed, + isLoading, + hasArtist, +}: GetHomeTasksModuleStateParams): HomeTasksModuleState { + if (isLoading || runsFailed || !runs) return { view: "hidden" }; + + if (runs.length > 0) + return { view: "runs", runs: runs.slice(0, MAX_HOME_RUNS) }; + + return hasArtist ? { view: "starter" } : { view: "hidden" }; +}