diff --git a/components/Artists/AddArtistDialog.tsx b/components/Artists/AddArtistDialog.tsx
new file mode 100644
index 000000000..8eac4e9dc
--- /dev/null
+++ b/components/Artists/AddArtistDialog.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist";
+import SpotifyArtistSearch from "./SpotifyArtistSearch";
+import type { SpotifyArtistSearchResult } from "@/types/spotify";
+
+/**
+ * Global "Add New Artist" dialog: search Spotify -> pick -> add to the roster.
+ * Opened by `toggleCreation` (Artists page, sidebar, header), replacing the old
+ * `/?q=create a new artist` redirect that dead-ended on the onboarding sequence.
+ */
+const AddArtistDialog = () => {
+ const { isCreationOpen, closeCreation } = useArtistProvider();
+ const { add, isAdding } = useAddSpotifyArtist();
+
+ const handleSelect = async (artist: SpotifyArtistSearchResult) => {
+ const added = await add(artist);
+ if (added) closeCreation();
+ };
+
+ return (
+
+ );
+};
+
+export default AddArtistDialog;
diff --git a/components/Artists/SpotifyArtistSearch.tsx b/components/Artists/SpotifyArtistSearch.tsx
new file mode 100644
index 000000000..b3ab0baa3
--- /dev/null
+++ b/components/Artists/SpotifyArtistSearch.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useState } from "react";
+import { Loader, Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { useSpotifyArtistSearch } from "@/hooks/useSpotifyArtistSearch";
+import formatFollowerCount from "@/lib/utils/formatFollowerCount";
+import type { SpotifyArtistSearchResult } from "@/types/spotify";
+
+interface SpotifyArtistSearchProps {
+ onSelect: (artist: SpotifyArtistSearchResult) => void;
+ /** Disables input + results while an add is in flight. */
+ isBusy?: boolean;
+ autoFocus?: boolean;
+}
+
+/**
+ * Shared Spotify artist typeahead: type -> see matches (avatar · name ·
+ * followers) -> pick. Reused by the "Add New Artist" dialog and (next slice)
+ * social enrichment, so it only searches and reports the choice — the caller
+ * owns what happens on select.
+ */
+const SpotifyArtistSearch = ({
+ onSelect,
+ isBusy,
+ autoFocus,
+}: SpotifyArtistSearchProps) => {
+ const [query, setQuery] = useState("");
+ const { results, isSearching } = useSpotifyArtistSearch(query);
+ const hasQuery = query.trim().length >= 2;
+
+ return (
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search Spotify for an artist"
+ className="pl-9"
+ aria-label="Search Spotify for an artist"
+ disabled={isBusy}
+ />
+
+
+
+ {isSearching && results.length === 0 && (
+
+ Searching...
+
+ )}
+ {!isSearching && hasQuery && results.length === 0 && (
+
+ No artists found.
+
+ )}
+ {results.map((artist) => (
+
+ ))}
+
+
+ );
+};
+
+export default SpotifyArtistSearch;
diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts
new file mode 100644
index 000000000..23e270d66
--- /dev/null
+++ b/hooks/useAddSpotifyArtist.ts
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState } from "react";
+import { usePrivy } from "@privy-io/react-auth";
+import { toast } from "sonner";
+import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist";
+import { useArtistProvider } from "@/providers/ArtistProvider";
+import { useOrganization } from "@/providers/OrganizationProvider";
+import type { SpotifyArtistSearchResult } from "@/types/spotify";
+
+/**
+ * Adds a Spotify-searched artist to the roster and refreshes + selects it.
+ * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider),
+ * mirroring `useAddRosterArtist`.
+ */
+export function useAddSpotifyArtist() {
+ const { getAccessToken } = usePrivy();
+ const { getArtists } = useArtistProvider();
+ const { selectedOrgId } = useOrganization();
+ const [isAdding, setIsAdding] = useState(false);
+
+ const add = async (artist: SpotifyArtistSearchResult): Promise
=> {
+ setIsAdding(true);
+ try {
+ const accessToken = await getAccessToken();
+ if (!accessToken) {
+ throw new Error("Please sign in to add an artist");
+ }
+ const created = await addSpotifyArtist(
+ accessToken,
+ artist,
+ selectedOrgId,
+ );
+ await getArtists(created.account_id);
+ toast.success(`${artist.name} added to your roster`);
+ return true;
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to add artist",
+ );
+ return false;
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ return { add, isAdding };
+}
diff --git a/hooks/useArtistMode.tsx b/hooks/useArtistMode.tsx
index 292ac04ea..308c8832b 100644
--- a/hooks/useArtistMode.tsx
+++ b/hooks/useArtistMode.tsx
@@ -1,24 +1,28 @@
import { useUserProvider } from "@/providers/UserProvder";
import { ArtistRecord } from "@/types/Artist";
import { SETTING_MODE } from "@/types/Setting";
-import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react";
const useArtistMode = (
clearParams: () => void,
- setEditableArtist: Dispatch>
+ setEditableArtist: Dispatch>,
) => {
const [settingMode, setSettingMode] = useState(SETTING_MODE.UPDATE);
const [isOpenSettingModal, setIsOpenSettingModal] = useState(false);
+ const [isCreationOpen, setIsCreationOpen] = useState(false);
const { email } = useUserProvider();
- const { push } = useRouter();
+ // Opens the shared Spotify-search dialog (AddArtistDialog). Previously this
+ // pushed `/?q=create a new artist`, which the onboarding router now
+ // intercepts for incomplete accounts — dead-ending in an /artists loop.
const toggleCreation = () => {
- clearParams();
if (!email) return;
- push("/?q=create a new artist");
+ clearParams();
+ setIsCreationOpen(true);
};
+ const closeCreation = () => setIsCreationOpen(false);
+
const toggleUpdate = (artist: ArtistRecord) => {
setSettingMode(SETTING_MODE.UPDATE);
setEditableArtist(artist);
@@ -32,6 +36,8 @@ const useArtistMode = (
toggleUpdate,
toggleSettingModal,
toggleCreation,
+ closeCreation,
+ isCreationOpen,
settingMode,
setSettingMode,
isOpenSettingModal,
diff --git a/hooks/useSpotifyArtistSearch.ts b/hooks/useSpotifyArtistSearch.ts
new file mode 100644
index 000000000..225b0b3e5
--- /dev/null
+++ b/hooks/useSpotifyArtistSearch.ts
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";
+import type {
+ SpotifyArtistSearchResult,
+ SpotifySearchResponse,
+} from "@/types/spotify";
+
+interface UseSpotifyArtistSearchResult {
+ results: SpotifyArtistSearchResult[];
+ isSearching: boolean;
+}
+
+/**
+ * Debounced Spotify artist typeahead against the existing public
+ * `GET /api/spotify/search?type=artist` endpoint. Aborts the in-flight
+ * request when the query changes so results never arrive out of order.
+ */
+export function useSpotifyArtistSearch(
+ query: string,
+): UseSpotifyArtistSearchResult {
+ const [results, setResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ useEffect(() => {
+ const trimmed = query.trim();
+ if (trimmed.length < 2) {
+ setResults([]);
+ setIsSearching(false);
+ return;
+ }
+
+ setIsSearching(true);
+ const controller = new AbortController();
+ const timer = setTimeout(async () => {
+ try {
+ const res = await fetch(
+ `${getClientApiBaseUrl()}/api/spotify/search?q=${encodeURIComponent(
+ trimmed,
+ )}&type=artist`,
+ { signal: controller.signal },
+ );
+ const data: SpotifySearchResponse = await res.json();
+ setResults(data.artists?.items ?? []);
+ } catch {
+ if (!controller.signal.aborted) setResults([]);
+ } finally {
+ if (!controller.signal.aborted) setIsSearching(false);
+ }
+ }, 300);
+
+ return () => {
+ controller.abort();
+ clearTimeout(timer);
+ };
+ }, [query]);
+
+ return { results, isSearching };
+}
diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts
new file mode 100644
index 000000000..e0aa8057c
--- /dev/null
+++ b/lib/artists/__tests__/addSpotifyArtist.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist";
+import { createRosterArtist } from "@/lib/artists/createRosterArtist";
+import saveArtist from "@/lib/saveArtist";
+import type { SpotifyArtistSearchResult } from "@/types/spotify";
+
+vi.mock("@/lib/artists/createRosterArtist", () => ({
+ createRosterArtist: vi.fn(),
+}));
+vi.mock("@/lib/saveArtist", () => ({ default: vi.fn() }));
+
+const spotifyArtist: SpotifyArtistSearchResult = {
+ id: "0xPoVNPnxIIUS1vrxAYV00",
+ name: "Del Water Gap",
+ type: "artist",
+ uri: "spotify:artist:0xPoVNPnxIIUS1vrxAYV00",
+ external_urls: {
+ spotify: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00",
+ },
+ images: [{ url: "https://i.scdn.co/image/big", height: 640, width: 640 }],
+ popularity: 60,
+ genres: [],
+ followers: { href: null, total: 317952 },
+};
+
+describe("addSpotifyArtist", () => {
+ const token = "tok";
+ const created = { id: "acc-1", account_id: "acc-1", name: "Del Water Gap" };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(createRosterArtist).mockResolvedValue(created as never);
+ vi.mocked(saveArtist).mockResolvedValue({
+ artist: { ...created, image: "https://i.scdn.co/image/big" },
+ } as never);
+ });
+
+ it("creates the artist by name then links its Spotify image + profile URL", async () => {
+ const result = await addSpotifyArtist(token, spotifyArtist, "org-1");
+
+ expect(createRosterArtist).toHaveBeenCalledWith(
+ token,
+ "Del Water Gap",
+ "org-1",
+ );
+ expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", {
+ image: "https://i.scdn.co/image/big",
+ profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify },
+ });
+ expect(result.account_id).toBe("acc-1");
+ });
+
+ it("omits the image when the Spotify result has none", async () => {
+ await addSpotifyArtist(token, { ...spotifyArtist, images: [] });
+ expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", {
+ profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify },
+ });
+ });
+});
diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts
new file mode 100644
index 000000000..441d0995a
--- /dev/null
+++ b/lib/artists/addSpotifyArtist.ts
@@ -0,0 +1,30 @@
+import type { ArtistRecord } from "@/types/Artist";
+import type { SpotifyArtistSearchResult } from "@/types/spotify";
+import { createRosterArtist } from "@/lib/artists/createRosterArtist";
+import saveArtist from "@/lib/saveArtist";
+
+/**
+ * Adds a Spotify-searched artist to the roster with real data: creates the
+ * artist by name (`POST /api/artists`, which only accepts a name), then links
+ * its Spotify profile URL and avatar image (`PATCH /api/artists/{id}`) so the
+ * roster card and reports have the correct image + a Spotify social to enrich.
+ */
+export async function addSpotifyArtist(
+ accessToken: string,
+ artist: SpotifyArtistSearchResult,
+ orgId?: string | null,
+): Promise {
+ const created = await createRosterArtist(accessToken, artist.name, orgId);
+
+ const imageUrl = artist.images?.[0]?.url;
+ const { artist: updated } = await saveArtist(
+ accessToken,
+ created.account_id,
+ {
+ ...(imageUrl ? { image: imageUrl } : {}),
+ profileUrls: { SPOTIFY: artist.external_urls.spotify },
+ },
+ );
+
+ return updated ?? created;
+}