Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1182acc
feat(link-wizard): search-assisted candidate selection UI
claude Aug 20, 2026
0a222f4
fix(link-wizard): address code review defects for #335
claude Aug 20, 2026
d76fbe1
feat(link-wizard): show provider search errors in wizard steps
claude Aug 21, 2026
bea9951
fix(link-wizard): address Copilot review nits
claude Aug 21, 2026
d0b6446
feat(link-wizard): stage description alongside image_url on candidate…
claude Aug 21, 2026
0d74475
refactor(link-wizard): address review feedback
claude Aug 21, 2026
93ecf7c
fix(link-wizard): show raw follower count below 1000
claude Aug 21, 2026
fe6abe4
fix(link-wizard): remove autoFocus from custom search input
claude Aug 21, 2026
5bf9413
refactor(link-wizard): extract next-batch prefetch into a custom hook
claude Aug 21, 2026
7af7d37
fix(link-wizard): fix accidental submit, unblock single-field saves, …
claude Aug 21, 2026
8d5a549
refactor(link-wizard): extract CandidateCard component, move LinkWiza…
claude Aug 21, 2026
241cf73
feat(link-wizard): let admins pick which candidate fields to apply, m…
claude Aug 21, 2026
7ebd6b6
refactor(link-wizard): decompose LinkWizardStep into smaller pieces
claude Aug 21, 2026
b1b4b35
Build update payload as a variable in LinkWizardStep onSubmit
claude Aug 21, 2026
3399d2d
Stage candidate URLs in a nested providerUrl object, flatten at submit
claude Aug 21, 2026
7462966
Move URL inputs into the Staged panel, always write on submit
claude Aug 21, 2026
7209853
Remove duplicate provider heading in SoundCloud/Spotify candidate cards
claude Aug 21, 2026
1ea31df
Move candidate query into ProviderCandidatesPanel, rename from Provid…
claude Aug 21, 2026
1570779
Move staged fields fully into form state, nest URL fields under provi…
claude Aug 21, 2026
b6d4536
Move URL_FIELDS into StagedFieldsPreview
claude Aug 21, 2026
b14952d
Remove redundant staged-image caption
claude Aug 21, 2026
f7c5e9d
Prefill staged description and image_url from the artist's existing v…
claude Aug 21, 2026
6e31ea0
Type StagedFieldsPreview's form prop as LinkStepData instead of any
claude Aug 21, 2026
2c60313
Fix candidate selection overwriting already-staged image/description
claude Aug 21, 2026
538d6a3
Extract batch artist search into useArtistBatchQuery hook
claude Aug 21, 2026
78a715a
Skip the mutation on Save & Next when nothing changed; retry same cus…
claude Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/api/artistSearch/mergeCandidateSelection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { mergeCandidateSelection } from "./mergeCandidateSelection";
import type { Candidate } from "./types";

describe("mergeCandidateSelection", () => {
it("sets providerUrl.spotify when url is selected for spotify", () => {
const candidate = makeCandidate();

const update = mergeCandidateSelection(candidate, "spotify", ["url"]);

expect(update.providerUrl).toEqual({ spotify: candidate.url });
});

it("sets providerUrl.soundcloud when url is selected for soundcloud", () => {
const candidate = makeCandidate({
url: "https://soundcloud.com/artist",
});

const update = mergeCandidateSelection(candidate, "soundcloud", ["url"]);

expect(update.providerUrl).toEqual({ soundcloud: candidate.url });
});

it("does not set providerUrl when url is not selected", () => {
const candidate = makeCandidate();

const update = mergeCandidateSelection(candidate, "spotify", ["image"]);

expect(update.providerUrl).toBeUndefined();
});

it("sets image_url when image is selected", () => {
const candidate = makeCandidate({
imageUrl: "https://example.com/img.jpg",
});

const update = mergeCandidateSelection(candidate, "spotify", ["image"]);

expect(update.image_url).toBe(candidate.imageUrl);
});

it("does not set image_url when the candidate has none", () => {
const candidate = makeCandidate({ imageUrl: null });

const update = mergeCandidateSelection(candidate, "spotify", ["image"]);

expect(update.image_url).toBeUndefined();
});

it("sets description when description is selected", () => {
const candidate = makeCandidate({ description: "A great artist." });

const update = mergeCandidateSelection(candidate, "spotify", [
"description",
]);

expect(update.description).toBe(candidate.description);
});

it("does not set description when the candidate has none", () => {
const candidate = makeCandidate({ description: null });

const update = mergeCandidateSelection(candidate, "spotify", [
"description",
]);

expect(update.description).toBeUndefined();
});

it("sets all requested fields when multiple are selected", () => {
const candidate = makeCandidate({
imageUrl: "https://example.com/img.jpg",
description: "A great artist.",
});

const update = mergeCandidateSelection(candidate, "spotify", [
"url",
"image",
"description",
]);

expect(update.providerUrl).toEqual({ spotify: candidate.url });
expect(update.image_url).toBe(candidate.imageUrl);
expect(update.description).toBe(candidate.description);
});

it("never writes genres to updates", () => {
const candidate = makeCandidate({ genres: ["rock", "pop"] });

const update = mergeCandidateSelection(candidate, "spotify", [
"url",
"image",
"description",
]);

expect(update).not.toHaveProperty("genres");
});

it("does not overwrite an already-staged image_url", () => {
const candidate = makeCandidate({
imageUrl: "https://example.com/other-image.jpg",
});

const update = mergeCandidateSelection(candidate, "spotify", ["image"], {
image_url: "https://example.com/first-image.jpg",
});

expect(update.image_url).toBeUndefined();
});

it("does not overwrite an already-staged description", () => {
const candidate = makeCandidate({ description: "Another bio." });

const update = mergeCandidateSelection(
candidate,
"spotify",
["description"],
{ description: "First staged bio." },
);

expect(update.description).toBeUndefined();
});

it("still sets providerUrl even when other fields are already staged", () => {
const candidate = makeCandidate();

const update = mergeCandidateSelection(
candidate,
"spotify",
["url", "image", "description"],
{
image_url: "https://example.com/first-image.jpg",
description: "First staged bio.",
},
);

expect(update.providerUrl).toEqual({ spotify: candidate.url });
});
});

function makeCandidate(overrides: Partial<Candidate> = {}): Candidate {
return {
name: "Test Artist",
url: "https://spotify.com/artist/123",
imageUrl: "https://example.com/image.jpg",
description: "A test artist bio.",
followers: 1000,
genres: ["rock", "pop"],
...overrides,
};
}
41 changes: 41 additions & 0 deletions src/api/artistSearch/mergeCandidateSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Candidate, Provider } from "./types";

export interface CandidateUpdate {
providerUrl?: Partial<Record<Provider, string>>;
image_url?: string | null;
description?: string | null;
}
Comment thread
chiptus marked this conversation as resolved.

export type SelectableField = "url" | "image" | "description";

export interface StagedValues {
image_url?: string | null | undefined;
description?: string | null | undefined;
}

export function mergeCandidateSelection(
candidate: Candidate,
provider: Provider,
fields: SelectableField[],
staged: StagedValues = {},
): CandidateUpdate {
Comment thread
chiptus marked this conversation as resolved.
const updates: CandidateUpdate = {};

if (fields.includes("url")) {
updates.providerUrl = { [provider]: candidate.url };
}

if (fields.includes("image") && candidate.imageUrl && !staged.image_url) {
updates.image_url = candidate.imageUrl;
}

if (
fields.includes("description") &&
candidate.description &&
!staged.description
) {
updates.description = candidate.description;
}

return updates;
}
34 changes: 34 additions & 0 deletions src/api/artistSearch/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from "zod";

export const providerSchema = z.enum(["soundcloud", "spotify"]);
export type Provider = z.infer<typeof providerSchema>;

export const candidateSchema = z.object({
name: z.string(),
url: z.string(),
imageUrl: z.string().nullable(),
description: z.string().nullable(),
followers: z.number().nullable(),
genres: z.array(z.string()),
});
export type Candidate = z.infer<typeof candidateSchema>;

export const searchResultSchema = z.object({
artistName: z.string(),
provider: providerSchema,
candidates: z.array(candidateSchema),
error: z.string().optional(),
});
export type SearchResult = z.infer<typeof searchResultSchema>;

export const searchResponseSchema = z.object({
results: z.array(searchResultSchema),
});
export type SearchResponse = z.infer<typeof searchResponseSchema>;

export const artistSearchKeys = {
all: ["artistSearch"] as const,
searches: () => [...artistSearchKeys.all, "search"] as const,
search: (artistNames: string[], provider?: Provider) =>
[...artistSearchKeys.searches(), { artistNames, provider }] as const,
};
32 changes: 32 additions & 0 deletions src/api/artistSearch/usePrefetchNextBatchLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Artist } from "@/api/artists/types";
import { searchArtistLinksQuery } from "./useSearchArtistLinksQuery";

export function usePrefetchNextBatchLinks(
artists: Artist[],
currentArtistId: string | undefined,
) {
const queryClient = useQueryClient();

useEffect(() => {
if (!currentArtistId || artists.length === 0) return;

const currentIdx = Math.max(
0,
artists.findIndex((a) => a.id === currentArtistId),
);
const positionInBatch = currentIdx % 10;

if (positionInBatch < 8) return;

const nextBatchStart = Math.floor(currentIdx / 10) * 10 + 10;
const nextBatchArtists = artists
.slice(nextBatchStart, nextBatchStart + 10)
.map((a) => a.name);

if (nextBatchArtists.length > 0) {
queryClient.prefetchQuery(searchArtistLinksQuery(nextBatchArtists));
}
}, [currentArtistId, artists, queryClient]);
}
55 changes: 55 additions & 0 deletions src/api/artistSearch/useSearchArtistLinksQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import {
artistSearchKeys,
searchResponseSchema,
type SearchResponse,
type Provider,
} from "./types";

async function fetchSearchArtistLinks(
artistNames: string[],
provider?: Provider,
): Promise<SearchResponse> {
if (artistNames.length === 0) {
return { results: [] };
}

const { data, error } = await supabase.functions.invoke(
"search-artist-links",
{
body: {
artistNames,
provider,
},
},
);

if (error) {
console.error("Error searching artist links:", error);
throw new Error("Failed to search artist links");
}

return searchResponseSchema.parse(data);
}

export function searchArtistLinksQuery(
artistNames: string[],
provider?: Provider,
) {
return queryOptions({
queryKey: artistSearchKeys.search(artistNames, provider),
queryFn: () => fetchSearchArtistLinks(artistNames, provider),
enabled: artistNames.length > 0,
staleTime: 1000 * 60 * 30,
});
}

export function useSearchArtistLinksQuery(
artistNames: string[],
provider?: Provider,
) {
return useQuery({
...searchArtistLinksQuery(artistNames, provider),
});
}
Loading
Loading