From 7c88616817a7990a2b86031b8a966c2de63a802a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 20 Jul 2026 16:08:40 -0500 Subject: [PATCH 1/3] feat: roster + socials verification onboarding steps (chat#1867) Two self-contained onboarding step components for the chat#1867 sequence, mounted standalone at /onboarding/roster so the slice is user-testable before the onboarding router lands: - ConfirmRosterStep: shows the auto-created artist(s) from the valuation flow, inline "add another artist" for multi-artist managers (existing POST /api/artists endpoint), confirm to advance. - VerifySocialsStep: per rostered artist, lists the auto-matched socials with handle + follower count; each match is confirmed or rejected, wrong matches are fixed by pasting the correct profile link (existing PATCH /api/artists/{id} profileUrls path), and artists with no socials record an explicit "none". Verification state is pure client-side logic (lib/onboarding/*, TDD'd): verdict reducers, per-artist and step-level resolution predicates, PATCH payload building with APPPLE->APPLE platform-key normalization, and a follower-count accessor tolerant of the API's snake_case rows behind chat's camelCase SOCIAL type. No new endpoints, tables, or context providers - hooks compose the existing ArtistProvider / OrganizationProvider / Privy auth. Co-Authored-By: Claude Fable 5 --- app/onboarding/roster/page.tsx | 5 + components/Onboarding/AddArtistForm.tsx | 63 +++++++++++++ components/Onboarding/ArtistSocialsCard.tsx | 93 +++++++++++++++++++ components/Onboarding/ConfirmRosterStep.tsx | 62 +++++++++++++ components/Onboarding/RosterArtistRow.tsx | 31 +++++++ components/Onboarding/RosterSocialsFlow.tsx | 37 ++++++++ components/Onboarding/RosterVerifiedPanel.tsx | 27 ++++++ components/Onboarding/SocialFixForm.tsx | 44 +++++++++ components/Onboarding/SocialVerifyRow.tsx | 72 ++++++++++++++ components/Onboarding/VerifySocialsStep.tsx | 67 +++++++++++++ hooks/onboarding/useAddRosterArtist.ts | 44 +++++++++ hooks/onboarding/useSocialFix.ts | 65 +++++++++++++ hooks/onboarding/useSocialsVerification.ts | 57 ++++++++++++ .../__tests__/createRosterArtist.test.ts | 78 ++++++++++++++++ lib/artists/createRosterArtist.ts | 40 ++++++++ .../__tests__/applySocialVerdict.test.ts | 59 ++++++++++++ .../__tests__/areAllArtistsResolved.test.ts | 32 +++++++ .../__tests__/buildSocialFixPayload.test.ts | 43 +++++++++ .../__tests__/findSocialIdByPlatform.test.ts | 30 ++++++ .../__tests__/getSocialFollowerCount.test.ts | 23 +++++ .../__tests__/isArtistSocialsResolved.test.ts | 57 ++++++++++++ .../__tests__/markArtistHasNoSocials.test.ts | 40 ++++++++ lib/onboarding/applySocialVerdict.ts | 25 +++++ lib/onboarding/areAllArtistsResolved.ts | 21 +++++ lib/onboarding/buildSocialFixPayload.ts | 24 +++++ lib/onboarding/findSocialIdByPlatform.ts | 18 ++++ lib/onboarding/getSocialFollowerCount.ts | 17 ++++ lib/onboarding/isArtistSocialsResolved.ts | 21 +++++ lib/onboarding/markArtistHasNoSocials.ts | 15 +++ lib/onboarding/normalizeSocialPlatform.ts | 9 ++ lib/onboarding/socialVerificationTypes.ts | 21 +++++ 31 files changed, 1240 insertions(+) create mode 100644 app/onboarding/roster/page.tsx create mode 100644 components/Onboarding/AddArtistForm.tsx create mode 100644 components/Onboarding/ArtistSocialsCard.tsx create mode 100644 components/Onboarding/ConfirmRosterStep.tsx create mode 100644 components/Onboarding/RosterArtistRow.tsx create mode 100644 components/Onboarding/RosterSocialsFlow.tsx create mode 100644 components/Onboarding/RosterVerifiedPanel.tsx create mode 100644 components/Onboarding/SocialFixForm.tsx create mode 100644 components/Onboarding/SocialVerifyRow.tsx create mode 100644 components/Onboarding/VerifySocialsStep.tsx create mode 100644 hooks/onboarding/useAddRosterArtist.ts create mode 100644 hooks/onboarding/useSocialFix.ts create mode 100644 hooks/onboarding/useSocialsVerification.ts create mode 100644 lib/artists/__tests__/createRosterArtist.test.ts create mode 100644 lib/artists/createRosterArtist.ts create mode 100644 lib/onboarding/__tests__/applySocialVerdict.test.ts create mode 100644 lib/onboarding/__tests__/areAllArtistsResolved.test.ts create mode 100644 lib/onboarding/__tests__/buildSocialFixPayload.test.ts create mode 100644 lib/onboarding/__tests__/findSocialIdByPlatform.test.ts create mode 100644 lib/onboarding/__tests__/getSocialFollowerCount.test.ts create mode 100644 lib/onboarding/__tests__/isArtistSocialsResolved.test.ts create mode 100644 lib/onboarding/__tests__/markArtistHasNoSocials.test.ts create mode 100644 lib/onboarding/applySocialVerdict.ts create mode 100644 lib/onboarding/areAllArtistsResolved.ts create mode 100644 lib/onboarding/buildSocialFixPayload.ts create mode 100644 lib/onboarding/findSocialIdByPlatform.ts create mode 100644 lib/onboarding/getSocialFollowerCount.ts create mode 100644 lib/onboarding/isArtistSocialsResolved.ts create mode 100644 lib/onboarding/markArtistHasNoSocials.ts create mode 100644 lib/onboarding/normalizeSocialPlatform.ts create mode 100644 lib/onboarding/socialVerificationTypes.ts diff --git a/app/onboarding/roster/page.tsx b/app/onboarding/roster/page.tsx new file mode 100644 index 000000000..93d2abfc0 --- /dev/null +++ b/app/onboarding/roster/page.tsx @@ -0,0 +1,5 @@ +import RosterSocialsFlow from "@/components/Onboarding/RosterSocialsFlow"; + +const OnboardingRoster = () => ; + +export default OnboardingRoster; diff --git a/components/Onboarding/AddArtistForm.tsx b/components/Onboarding/AddArtistForm.tsx new file mode 100644 index 000000000..3c32b393c --- /dev/null +++ b/components/Onboarding/AddArtistForm.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { Loader, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist"; + +/** Inline "add another artist" form for multi-artist managers. */ +const AddArtistForm = () => { + const { addArtist, isAdding } = useAddRosterArtist(); + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const added = await addArtist(name); + if (added) { + setName(""); + setIsOpen(false); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ setName(e.target.value)} + placeholder="Artist name" + disabled={isAdding} + aria-label="Artist name" + /> + + +
+ ); +}; + +export default AddArtistForm; diff --git a/components/Onboarding/ArtistSocialsCard.tsx b/components/Onboarding/ArtistSocialsCard.tsx new file mode 100644 index 000000000..3d4c9a7b9 --- /dev/null +++ b/components/Onboarding/ArtistSocialsCard.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ArtistSocialsVerification } from "@/lib/onboarding/socialVerificationTypes"; +import type { ArtistRecord } from "@/types/Artist"; +import SocialFixForm from "./SocialFixForm"; +import SocialVerifyRow from "./SocialVerifyRow"; + +interface ArtistSocialsCardProps { + artist: ArtistRecord; + verification: ArtistSocialsVerification | undefined; + isResolved: boolean; + isFixing: boolean; + onConfirm: (socialId: string) => void; + onReject: (socialId: string) => void; + onMarkNone: () => void; + onFix: (url: string) => Promise; +} + +/** Per-artist socials verification: confirm-or-fix each match, or record "none". */ +const ArtistSocialsCard = ({ + artist, + verification, + isResolved, + isFixing, + onConfirm, + onReject, + onMarkNone, + onFix, +}: ArtistSocialsCardProps) => { + const socials = artist.account_socials ?? []; + const hasRejection = Object.values(verification?.verdicts ?? {}).includes( + "rejected", + ); + const showFixForm = hasRejection || socials.length === 0; + + return ( +
+
+

+ {artist.name || "Untitled artist"} +

+ {isResolved && ( + + + {verification?.none ? "No socials" : "Verified"} + + )} +
+ + {socials.length === 0 ? ( +

+ No socials were auto-matched for this artist. +

+ ) : ( +
+ {socials.map((social) => ( + onConfirm(social.id)} + onReject={() => onReject(social.id)} + /> + ))} +
+ )} + + {showFixForm && ( + + )} + + {!verification?.none && ( + + )} +
+ ); +}; + +export default ArtistSocialsCard; diff --git a/components/Onboarding/ConfirmRosterStep.tsx b/components/Onboarding/ConfirmRosterStep.tsx new file mode 100644 index 000000000..67fdd9e3f --- /dev/null +++ b/components/Onboarding/ConfirmRosterStep.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import AddArtistForm from "./AddArtistForm"; +import RosterArtistRow from "./RosterArtistRow"; + +/** + * Onboarding step: confirm the auto-created roster. Shows the artists + * the valuation flow created, lets multi-artist managers add the rest, + * and advances once the user confirms the list. + */ +const ConfirmRosterStep = ({ onConfirmed }: { onConfirmed: () => void }) => { + const { sorted, isLoading } = useArtistProvider(); + const artists = sorted.filter((artist) => !artist.isWorkspace); + + return ( +
+
+

+ Confirm your roster +

+

+ We set {artists.length === 1 ? "this artist" : "these artists"} up + from your valuation. Add anyone else you manage — you can verify their + socials next. +

+
+ +
+ {isLoading ? ( + <> + + + + ) : artists.length === 0 ? ( +

+ No artists on your roster yet. Add your first artist below. +

+ ) : ( + artists.map((artist) => ( + + )) + )} + +
+ + +
+ ); +}; + +export default ConfirmRosterStep; diff --git a/components/Onboarding/RosterArtistRow.tsx b/components/Onboarding/RosterArtistRow.tsx new file mode 100644 index 000000000..47b8e64ce --- /dev/null +++ b/components/Onboarding/RosterArtistRow.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import type { ArtistRecord } from "@/types/Artist"; + +const RosterArtistRow = ({ artist }: { artist: ArtistRecord }) => { + const socialsCount = artist.account_socials?.length ?? 0; + const socialsLabel = + socialsCount === 0 + ? "No socials matched yet" + : `${socialsCount} social ${socialsCount === 1 ? "profile" : "profiles"} matched`; + + return ( +
+ + {artist.image ? : null} + + {(artist.name || "?").charAt(0).toUpperCase()} + + +
+

+ {artist.name || "Untitled artist"} +

+

{socialsLabel}

+
+
+ ); +}; + +export default RosterArtistRow; diff --git a/components/Onboarding/RosterSocialsFlow.tsx b/components/Onboarding/RosterSocialsFlow.tsx new file mode 100644 index 000000000..203633aa8 --- /dev/null +++ b/components/Onboarding/RosterSocialsFlow.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; +import ConfirmRosterStep from "./ConfirmRosterStep"; +import RosterVerifiedPanel from "./RosterVerifiedPanel"; +import VerifySocialsStep from "./VerifySocialsStep"; + +type FlowStep = "roster" | "socials" | "done"; + +/** + * Standalone container for the roster + socials onboarding steps so the + * slice is user-testable at /onboarding/roster today. The sibling + * onboarding-sequence router (chat#1867) mounts `ConfirmRosterStep` and + * `VerifySocialsStep` directly with its own state-derived stepping. + */ +const RosterSocialsFlow = () => { + const [step, setStep] = useState("roster"); + + return ( +
+ {step !== "done" && ( +

+ Step {step === "roster" ? "1" : "2"} of 2 +

+ )} + {step === "roster" && ( + setStep("socials")} /> + )} + {step === "socials" && ( + setStep("done")} /> + )} + {step === "done" && } +
+ ); +}; + +export default RosterSocialsFlow; diff --git a/components/Onboarding/RosterVerifiedPanel.tsx b/components/Onboarding/RosterVerifiedPanel.tsx new file mode 100644 index 000000000..aba0726f1 --- /dev/null +++ b/components/Onboarding/RosterVerifiedPanel.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; +import { CheckCircle2 } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** Shown when both roster and socials steps are complete. */ +const RosterVerifiedPanel = () => ( +
+ +
+

+ Roster verified +

+

+ Your artists and their socials are confirmed. Reports, research, and + tasks will use them from here on. +

+
+ + Back to Recoup + +
+); + +export default RosterVerifiedPanel; diff --git a/components/Onboarding/SocialFixForm.tsx b/components/Onboarding/SocialFixForm.tsx new file mode 100644 index 000000000..3699ea268 --- /dev/null +++ b/components/Onboarding/SocialFixForm.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { Loader } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface SocialFixFormProps { + placeholder: string; + isSubmitting: boolean; + onSubmit: (url: string) => Promise; +} + +/** Paste-a-link form used to replace a wrong social or add a missing one. */ +const SocialFixForm = ({ + placeholder, + isSubmitting, + onSubmit, +}: SocialFixFormProps) => { + const [url, setUrl] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const saved = await onSubmit(url); + if (saved) setUrl(""); + }; + + return ( +
+ setUrl(e.target.value)} + placeholder={placeholder} + disabled={isSubmitting} + aria-label="Profile link" + /> + +
+ ); +}; + +export default SocialFixForm; diff --git a/components/Onboarding/SocialVerifyRow.tsx b/components/Onboarding/SocialVerifyRow.tsx new file mode 100644 index 000000000..63ca3eee9 --- /dev/null +++ b/components/Onboarding/SocialVerifyRow.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; +import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount"; +import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; +import formatFollowerCount from "@/lib/utils/formatFollowerCount"; +import type { SocialVerdict } from "@/lib/onboarding/socialVerificationTypes"; +import type { SOCIAL } from "@/types/Agent"; + +interface SocialVerifyRowProps { + social: SOCIAL; + verdict: SocialVerdict | undefined; + onConfirm: () => void; + onReject: () => void; +} + +/** One auto-matched social: platform, handle, followers, confirm-or-reject. */ +const SocialVerifyRow = ({ + social, + verdict, + onConfirm, + onReject, +}: SocialVerifyRowProps) => { + const platform = getPlatformDisplayName( + getSocialPlatformByLink(social.link || ""), + ); + const followerCount = getSocialFollowerCount(social); + const handle = social.username + ? social.username.startsWith("@") + ? social.username + : `@${social.username}` + : social.link; + + return ( +
+
+

{platform}

+

+ {handle} + {followerCount !== null && + ` · ${formatFollowerCount(followerCount)} followers`} +

+
+
+ + +
+
+ ); +}; + +export default SocialVerifyRow; diff --git a/components/Onboarding/VerifySocialsStep.tsx b/components/Onboarding/VerifySocialsStep.tsx new file mode 100644 index 000000000..7a36adb27 --- /dev/null +++ b/components/Onboarding/VerifySocialsStep.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useSocialFix } from "@/hooks/onboarding/useSocialFix"; +import { useSocialsVerification } from "@/hooks/onboarding/useSocialsVerification"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import ArtistSocialsCard from "./ArtistSocialsCard"; + +/** + * Onboarding step: verify the auto-matched socials for every rostered + * artist. Each match is confirmed or fixed (wrong auto-matches are a + * known failure mode); artists without socials record an explicit none. + */ +const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => { + const { sorted } = useArtistProvider(); + const artists = sorted.filter((artist) => !artist.isWorkspace); + const { state, setVerdict, markNone, isResolved, allResolved } = + useSocialsVerification(artists); + const { fixSocial, fixingArtistId } = useSocialFix((artistId, socialId) => + setVerdict(artistId, socialId, "confirmed"), + ); + + return ( +
+
+

+ Verify socials +

+

+ We auto-matched these profiles. Confirm each one, or fix any that + point at the wrong account — reports and tasks pull from them. +

+
+ +
+ {artists.map((artist) => ( + + setVerdict(artist.account_id, socialId, "confirmed") + } + onReject={(socialId) => + setVerdict(artist.account_id, socialId, "rejected") + } + onMarkNone={() => markNone(artist.account_id)} + onFix={(url) => fixSocial(artist, url)} + /> + ))} +
+ + +
+ ); +}; + +export default VerifySocialsStep; diff --git a/hooks/onboarding/useAddRosterArtist.ts b/hooks/onboarding/useAddRosterArtist.ts new file mode 100644 index 000000000..965ec3d6f --- /dev/null +++ b/hooks/onboarding/useAddRosterArtist.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useOrganization } from "@/providers/OrganizationProvider"; + +/** + * "Add another artist" for the onboarding roster step. Creates the + * artist through the existing `POST /api/artists` endpoint and refreshes + * the shared roster (ArtistProvider) so the new artist appears in-flow. + */ +export function useAddRosterArtist() { + const { getAccessToken } = usePrivy(); + const { getArtists } = useArtistProvider(); + const { selectedOrgId } = useOrganization(); + const [isAdding, setIsAdding] = useState(false); + + const addArtist = async (name: string): Promise => { + const trimmed = name.trim(); + if (!trimmed) return false; + setIsAdding(true); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to add an artist"); + } + await createRosterArtist(accessToken, trimmed, selectedOrgId); + await getArtists(); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to add artist", + ); + return false; + } finally { + setIsAdding(false); + } + }; + + return { addArtist, isAdding }; +} diff --git a/hooks/onboarding/useSocialFix.ts b/hooks/onboarding/useSocialFix.ts new file mode 100644 index 000000000..939e0007e --- /dev/null +++ b/hooks/onboarding/useSocialFix.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import saveArtist from "@/lib/saveArtist"; +import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload"; +import { findSocialIdByPlatform } from "@/lib/onboarding/findSocialIdByPlatform"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import type { ArtistRecord } from "@/types/Artist"; + +/** + * Replaces a wrong auto-matched social (or adds a missing one) with a + * corrected profile URL via the existing PATCH /api/artists/{id} + * `profileUrls` path, then refreshes the roster and reports the + * replacement social id so the caller can auto-confirm it. + */ +export function useSocialFix( + onFixed: (artistId: string, socialId: string) => void, +) { + const { getAccessToken } = usePrivy(); + const { getArtists } = useArtistProvider(); + const [fixingArtistId, setFixingArtistId] = useState(null); + + const fixSocial = async ( + artist: ArtistRecord, + url: string, + ): Promise => { + const payload = buildSocialFixPayload(url); + if (!payload) { + toast.error( + "Paste a profile link from Spotify, Instagram, TikTok, YouTube, X, Facebook, Threads, or Apple Music", + ); + return false; + } + setFixingArtistId(artist.account_id); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to update socials"); + } + const data = await saveArtist(accessToken, artist.account_id, { + profileUrls: payload.profileUrls, + }); + const socialId = findSocialIdByPlatform( + data.artist?.account_socials ?? [], + payload.platform, + ); + if (socialId) { + onFixed(artist.account_id, socialId); + } + await getArtists(); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update social", + ); + return false; + } finally { + setFixingArtistId(null); + } + }; + + return { fixSocial, fixingArtistId }; +} diff --git a/hooks/onboarding/useSocialsVerification.ts b/hooks/onboarding/useSocialsVerification.ts new file mode 100644 index 000000000..b467d2325 --- /dev/null +++ b/hooks/onboarding/useSocialsVerification.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { applySocialVerdict } from "@/lib/onboarding/applySocialVerdict"; +import { areAllArtistsResolved } from "@/lib/onboarding/areAllArtistsResolved"; +import { isArtistSocialsResolved } from "@/lib/onboarding/isArtistSocialsResolved"; +import { markArtistHasNoSocials } from "@/lib/onboarding/markArtistHasNoSocials"; +import type { + SocialsVerificationState, + SocialVerdict, +} from "@/lib/onboarding/socialVerificationTypes"; +import type { ArtistRecord } from "@/types/Artist"; + +/** + * Owns the confirm/reject/none verdict state for the socials + * verification step, plus the per-artist and step-level resolution + * predicates derived from the live roster. + */ +export function useSocialsVerification(artists: ArtistRecord[]) { + const [state, setState] = useState({}); + + const setVerdict = useCallback( + (artistId: string, socialId: string, verdict: SocialVerdict) => { + setState((prev) => applySocialVerdict(prev, artistId, socialId, verdict)); + }, + [], + ); + + const markNone = useCallback((artistId: string) => { + setState((prev) => markArtistHasNoSocials(prev, artistId)); + }, []); + + const artistSocialIds = useMemo( + () => + artists.map((artist) => ({ + artistId: artist.account_id, + socialIds: (artist.account_socials ?? []).map((social) => social.id), + })), + [artists], + ); + + const allResolved = useMemo( + () => areAllArtistsResolved(state, artistSocialIds), + [state, artistSocialIds], + ); + + const isResolved = useCallback( + (artist: ArtistRecord) => + isArtistSocialsResolved( + state[artist.account_id], + (artist.account_socials ?? []).map((social) => social.id), + ), + [state], + ); + + return { state, setVerdict, markNone, isResolved, allResolved }; +} diff --git a/lib/artists/__tests__/createRosterArtist.test.ts b/lib/artists/__tests__/createRosterArtist.test.ts new file mode 100644 index 000000000..50cbe4c0c --- /dev/null +++ b/lib/artists/__tests__/createRosterArtist.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +vi.mock("@/lib/api/getClientApiBaseUrl", () => ({ + getClientApiBaseUrl: vi.fn(), +})); + +describe("createRosterArtist", () => { + const accessToken = "test-token"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientApiBaseUrl).mockReturnValue( + "https://api.recoupable.com", + ); + }); + + it("POSTs the artist name with bearer auth and returns the artist", async () => { + const artist = { id: "artist-1", account_id: "artist-1", name: "New Act" }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ artist }), + }) as unknown as typeof fetch; + + const result = await createRosterArtist(accessToken, "New Act"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.recoupable.com/api/artists", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + body: JSON.stringify({ name: "New Act" }), + }), + ); + expect(result).toEqual(artist); + }); + + it("includes organization_id when an org is provided", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ artist: { id: "a" } }), + }) as unknown as typeof fetch; + + await createRosterArtist(accessToken, "New Act", "org-1"); + + const body = JSON.parse( + vi.mocked(global.fetch).mock.calls[0][1]?.body as string, + ); + expect(body).toEqual({ name: "New Act", organization_id: "org-1" }); + }); + + it("throws the API error message on failure responses", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: vi + .fn() + .mockResolvedValue({ status: "error", error: "name is required" }), + }) as unknown as typeof fetch; + + await expect(createRosterArtist(accessToken, "")).rejects.toThrow( + "name is required", + ); + }); + + it("throws when the response has no artist", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + }) as unknown as typeof fetch; + + await expect(createRosterArtist(accessToken, "New Act")).rejects.toThrow( + "Failed to create artist", + ); + }); +}); diff --git a/lib/artists/createRosterArtist.ts b/lib/artists/createRosterArtist.ts new file mode 100644 index 000000000..d869ae844 --- /dev/null +++ b/lib/artists/createRosterArtist.ts @@ -0,0 +1,40 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import type { ArtistRecord } from "@/types/Artist"; + +interface CreateArtistResponse { + artist?: ArtistRecord; + error?: string; +} + +/** + * Creates a new artist on the roster via the existing Recoup API + * `POST /api/artists` endpoint (Privy bearer auth — the owning account + * is inferred from the token). Mirrors `fetchArtists` / `createTask`. + */ +export async function createRosterArtist( + accessToken: string, + name: string, + orgId?: string | null, +): Promise { + const body: Record = { name }; + if (orgId) { + body.organization_id = orgId; + } + + const response = await fetch(`${getClientApiBaseUrl()}/api/artists`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data: CreateArtistResponse = await response.json(); + + if (!response.ok || !data.artist) { + throw new Error(data.error || "Failed to create artist"); + } + + return data.artist; +} diff --git a/lib/onboarding/__tests__/applySocialVerdict.test.ts b/lib/onboarding/__tests__/applySocialVerdict.test.ts new file mode 100644 index 000000000..746693bdb --- /dev/null +++ b/lib/onboarding/__tests__/applySocialVerdict.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { applySocialVerdict } from "@/lib/onboarding/applySocialVerdict"; +import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; + +describe("applySocialVerdict", () => { + it("records a confirmed verdict for a new artist", () => { + const next = applySocialVerdict({}, "artist-1", "social-1", "confirmed"); + expect(next["artist-1"]).toEqual({ + verdicts: { "social-1": "confirmed" }, + none: false, + }); + }); + + it("records a rejected verdict alongside existing verdicts", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: { "social-1": "confirmed" }, none: false }, + }; + const next = applySocialVerdict(state, "artist-1", "social-2", "rejected"); + expect(next["artist-1"].verdicts).toEqual({ + "social-1": "confirmed", + "social-2": "rejected", + }); + }); + + it("overwrites a previous verdict for the same social", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: { "social-1": "rejected" }, none: false }, + }; + const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); + expect(next["artist-1"].verdicts["social-1"]).toBe("confirmed"); + }); + + it("clears an explicit none flag when any verdict is applied", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: {}, none: true }, + }; + const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); + expect(next["artist-1"].none).toBe(false); + }); + + it("does not mutate the input state", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: {}, none: false }, + }; + applySocialVerdict(state, "artist-1", "social-1", "confirmed"); + expect(state["artist-1"].verdicts).toEqual({}); + }); + + it("leaves other artists untouched", () => { + const state: SocialsVerificationState = { + "artist-2": { verdicts: { s: "rejected" }, none: false }, + }; + const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); + expect(next["artist-2"]).toEqual({ + verdicts: { s: "rejected" }, + none: false, + }); + }); +}); diff --git a/lib/onboarding/__tests__/areAllArtistsResolved.test.ts b/lib/onboarding/__tests__/areAllArtistsResolved.test.ts new file mode 100644 index 000000000..972844fce --- /dev/null +++ b/lib/onboarding/__tests__/areAllArtistsResolved.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { areAllArtistsResolved } from "@/lib/onboarding/areAllArtistsResolved"; +import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; + +describe("areAllArtistsResolved", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: { s1: "confirmed" }, none: false }, + "artist-2": { verdicts: {}, none: true }, + }; + + it("is true when every artist is resolved", () => { + expect( + areAllArtistsResolved(state, [ + { artistId: "artist-1", socialIds: ["s1"] }, + { artistId: "artist-2", socialIds: [] }, + ]), + ).toBe(true); + }); + + it("is false when any artist is unresolved", () => { + expect( + areAllArtistsResolved(state, [ + { artistId: "artist-1", socialIds: ["s1"] }, + { artistId: "artist-3", socialIds: ["s9"] }, + ]), + ).toBe(false); + }); + + it("is vacuously true for an empty artist list", () => { + expect(areAllArtistsResolved({}, [])).toBe(true); + }); +}); diff --git a/lib/onboarding/__tests__/buildSocialFixPayload.test.ts b/lib/onboarding/__tests__/buildSocialFixPayload.test.ts new file mode 100644 index 000000000..7042d1e7d --- /dev/null +++ b/lib/onboarding/__tests__/buildSocialFixPayload.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload"; + +describe("buildSocialFixPayload", () => { + it("builds a PATCH profileUrls payload keyed by detected platform", () => { + expect(buildSocialFixPayload("https://instagram.com/laequis")).toEqual({ + platform: "INSTAGRAM", + profileUrls: { INSTAGRAM: "https://instagram.com/laequis" }, + }); + }); + + it("detects platforms from bare domains without a scheme", () => { + expect(buildSocialFixPayload("tiktok.com/@laequis")).toEqual({ + platform: "TIKTOK", + profileUrls: { TIKTOK: "tiktok.com/@laequis" }, + }); + }); + + it("normalizes the misspelled APPPLE platform key to APPLE for the API", () => { + const payload = buildSocialFixPayload( + "https://music.apple.com/us/artist/x/123", + ); + expect(payload).toEqual({ + platform: "APPLE", + profileUrls: { APPLE: "https://music.apple.com/us/artist/x/123" }, + }); + }); + + it("trims surrounding whitespace", () => { + expect(buildSocialFixPayload(" https://x.com/laequis ")).toEqual({ + platform: "TWITTER", + profileUrls: { TWITTER: "https://x.com/laequis" }, + }); + }); + + it("returns null for an unrecognized platform", () => { + expect(buildSocialFixPayload("https://example.com/artist")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(buildSocialFixPayload(" ")).toBeNull(); + }); +}); diff --git a/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts b/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts new file mode 100644 index 000000000..11cf46bfd --- /dev/null +++ b/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { findSocialIdByPlatform } from "@/lib/onboarding/findSocialIdByPlatform"; +import type { SOCIAL } from "@/types/Agent"; + +const social = (id: string, type: string, link: string): SOCIAL => + ({ id, type, link }) as SOCIAL; + +describe("findSocialIdByPlatform", () => { + const socials = [ + social("s-ig", "INSTAGRAM", "https://instagram.com/a"), + social("s-tw", "TWITTER", "https://x.com/a"), + ]; + + it("finds the social id for a platform", () => { + expect(findSocialIdByPlatform(socials, "TWITTER")).toBe("s-tw"); + }); + + it("returns null when no social matches", () => { + expect(findSocialIdByPlatform(socials, "TIKTOK")).toBeNull(); + }); + + it("matches APPLE against the legacy APPPLE type spelling", () => { + const apple = [social("s-ap", "APPPLE", "https://music.apple.com/a")]; + expect(findSocialIdByPlatform(apple, "APPLE")).toBe("s-ap"); + }); + + it("returns null for an empty list", () => { + expect(findSocialIdByPlatform([], "INSTAGRAM")).toBeNull(); + }); +}); diff --git a/lib/onboarding/__tests__/getSocialFollowerCount.test.ts b/lib/onboarding/__tests__/getSocialFollowerCount.test.ts new file mode 100644 index 000000000..d9141bcd8 --- /dev/null +++ b/lib/onboarding/__tests__/getSocialFollowerCount.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount"; + +describe("getSocialFollowerCount", () => { + it("reads the API's snake_case follower_count", () => { + expect(getSocialFollowerCount({ follower_count: 1200 })).toBe(1200); + }); + + it("falls back to the camelCase followerCount used by chat types", () => { + expect(getSocialFollowerCount({ followerCount: 88 })).toBe(88); + }); + + it("prefers snake_case when both are present", () => { + expect( + getSocialFollowerCount({ follower_count: 5, followerCount: 9 }), + ).toBe(5); + }); + + it("returns null when neither field is a number", () => { + expect(getSocialFollowerCount({})).toBeNull(); + expect(getSocialFollowerCount({ follower_count: null })).toBeNull(); + }); +}); diff --git a/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts b/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts new file mode 100644 index 000000000..dcc27879e --- /dev/null +++ b/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { isArtistSocialsResolved } from "@/lib/onboarding/isArtistSocialsResolved"; + +describe("isArtistSocialsResolved", () => { + it("is false when the artist has no verification entry", () => { + expect(isArtistSocialsResolved(undefined, ["s1"])).toBe(false); + }); + + it("is false for an artist with zero socials until none is explicit", () => { + expect(isArtistSocialsResolved(undefined, [])).toBe(false); + expect(isArtistSocialsResolved({ verdicts: {}, none: true }, [])).toBe( + true, + ); + }); + + it("is true when explicit none is recorded regardless of socials", () => { + expect( + isArtistSocialsResolved({ verdicts: {}, none: true }, ["s1", "s2"]), + ).toBe(true); + }); + + it("is false while any social lacks a verdict", () => { + expect( + isArtistSocialsResolved({ verdicts: { s1: "confirmed" }, none: false }, [ + "s1", + "s2", + ]), + ).toBe(false); + }); + + it("is false when every verdict is rejected (needs explicit none)", () => { + expect( + isArtistSocialsResolved( + { verdicts: { s1: "rejected", s2: "rejected" }, none: false }, + ["s1", "s2"], + ), + ).toBe(false); + }); + + it("is true when all socials have verdicts and at least one is confirmed", () => { + expect( + isArtistSocialsResolved( + { verdicts: { s1: "confirmed", s2: "rejected" }, none: false }, + ["s1", "s2"], + ), + ).toBe(true); + }); + + it("ignores stale verdicts for socials no longer on the artist", () => { + expect( + isArtistSocialsResolved( + { verdicts: { gone: "confirmed" }, none: false }, + ["s1"], + ), + ).toBe(false); + }); +}); diff --git a/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts b/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts new file mode 100644 index 000000000..d863e04ee --- /dev/null +++ b/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { markArtistHasNoSocials } from "@/lib/onboarding/markArtistHasNoSocials"; +import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; + +describe("markArtistHasNoSocials", () => { + it("sets the explicit none flag for an unseen artist", () => { + const next = markArtistHasNoSocials({}, "artist-1"); + expect(next["artist-1"]).toEqual({ verdicts: {}, none: true }); + }); + + it("resets any existing verdicts — none supersedes them", () => { + const state: SocialsVerificationState = { + "artist-1": { + verdicts: { "social-1": "confirmed", "social-2": "rejected" }, + none: false, + }, + }; + const next = markArtistHasNoSocials(state, "artist-1"); + expect(next["artist-1"]).toEqual({ verdicts: {}, none: true }); + }); + + it("does not mutate the input state", () => { + const state: SocialsVerificationState = { + "artist-1": { verdicts: { s: "confirmed" }, none: false }, + }; + markArtistHasNoSocials(state, "artist-1"); + expect(state["artist-1"].none).toBe(false); + }); + + it("leaves other artists untouched", () => { + const state: SocialsVerificationState = { + "artist-2": { verdicts: { s: "confirmed" }, none: false }, + }; + const next = markArtistHasNoSocials(state, "artist-1"); + expect(next["artist-2"]).toEqual({ + verdicts: { s: "confirmed" }, + none: false, + }); + }); +}); diff --git a/lib/onboarding/applySocialVerdict.ts b/lib/onboarding/applySocialVerdict.ts new file mode 100644 index 000000000..7540f6197 --- /dev/null +++ b/lib/onboarding/applySocialVerdict.ts @@ -0,0 +1,25 @@ +import type { + SocialsVerificationState, + SocialVerdict, +} from "./socialVerificationTypes"; + +/** + * Immutably records a confirm/reject verdict for one social on one artist. + * Any verdict clears a previous explicit "none" — the user is engaging + * with the matched socials again. + */ +export function applySocialVerdict( + state: SocialsVerificationState, + artistId: string, + socialId: string, + verdict: SocialVerdict, +): SocialsVerificationState { + const current = state[artistId] ?? { verdicts: {}, none: false }; + return { + ...state, + [artistId]: { + verdicts: { ...current.verdicts, [socialId]: verdict }, + none: false, + }, + }; +} diff --git a/lib/onboarding/areAllArtistsResolved.ts b/lib/onboarding/areAllArtistsResolved.ts new file mode 100644 index 000000000..a2741c677 --- /dev/null +++ b/lib/onboarding/areAllArtistsResolved.ts @@ -0,0 +1,21 @@ +import { isArtistSocialsResolved } from "./isArtistSocialsResolved"; +import type { SocialsVerificationState } from "./socialVerificationTypes"; + +export interface ArtistSocialIds { + artistId: string; + socialIds: string[]; +} + +/** + * The socials verification step completes when every rostered artist is + * resolved (see `isArtistSocialsResolved`). Vacuously true for an empty + * roster — callers gate the step on having at least one artist. + */ +export function areAllArtistsResolved( + state: SocialsVerificationState, + artists: ArtistSocialIds[], +): boolean { + return artists.every(({ artistId, socialIds }) => + isArtistSocialsResolved(state[artistId], socialIds), + ); +} diff --git a/lib/onboarding/buildSocialFixPayload.ts b/lib/onboarding/buildSocialFixPayload.ts new file mode 100644 index 000000000..2bf82748d --- /dev/null +++ b/lib/onboarding/buildSocialFixPayload.ts @@ -0,0 +1,24 @@ +import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; +import { normalizeSocialPlatform } from "./normalizeSocialPlatform"; + +export interface SocialFixPayload { + /** API platform key, e.g. "INSTAGRAM" (Apple normalized to "APPLE"). */ + platform: string; + /** Body for the existing PATCH /api/artists/{id} `profileUrls` field. */ + profileUrls: Record; +} + +/** + * Builds the PATCH payload that replaces an artist's social for the + * platform detected from a corrected profile URL. Returns null when the + * URL is empty or doesn't map to a supported platform. + */ +export function buildSocialFixPayload(url: string): SocialFixPayload | null { + const trimmed = url.trim(); + if (!trimmed) return null; + + const platform = normalizeSocialPlatform(getSocialPlatformByLink(trimmed)); + if (platform === "NONE") return null; + + return { platform, profileUrls: { [platform]: trimmed } }; +} diff --git a/lib/onboarding/findSocialIdByPlatform.ts b/lib/onboarding/findSocialIdByPlatform.ts new file mode 100644 index 000000000..e53eb0f01 --- /dev/null +++ b/lib/onboarding/findSocialIdByPlatform.ts @@ -0,0 +1,18 @@ +import type { SOCIAL } from "@/types/Agent"; +import { normalizeSocialPlatform } from "./normalizeSocialPlatform"; + +/** + * Finds the id of an artist's social for a platform key. Used after a + * fix PATCH to locate the replacement social in the refreshed roster so + * it can be auto-confirmed. Tolerates the legacy "APPPLE" spelling. + */ +export function findSocialIdByPlatform( + socials: SOCIAL[], + platform: string, +): string | null { + const target = normalizeSocialPlatform(platform); + const match = socials.find( + (social) => normalizeSocialPlatform(social.type) === target, + ); + return match?.id ?? null; +} diff --git a/lib/onboarding/getSocialFollowerCount.ts b/lib/onboarding/getSocialFollowerCount.ts new file mode 100644 index 000000000..1cddda0d3 --- /dev/null +++ b/lib/onboarding/getSocialFollowerCount.ts @@ -0,0 +1,17 @@ +interface SocialFollowerFields { + follower_count?: number | null; + followerCount?: number | null; +} + +/** + * The roster API spreads raw `socials` rows (snake_case `follower_count`) + * while chat's `SOCIAL` type declares camelCase `followerCount`. Read + * whichever is present so the UI survives both shapes. + */ +export function getSocialFollowerCount( + social: SocialFollowerFields, +): number | null { + if (typeof social.follower_count === "number") return social.follower_count; + if (typeof social.followerCount === "number") return social.followerCount; + return null; +} diff --git a/lib/onboarding/isArtistSocialsResolved.ts b/lib/onboarding/isArtistSocialsResolved.ts new file mode 100644 index 000000000..c932fc3be --- /dev/null +++ b/lib/onboarding/isArtistSocialsResolved.ts @@ -0,0 +1,21 @@ +import type { ArtistSocialsVerification } from "./socialVerificationTypes"; + +/** + * An artist's socials step is resolved when the user either recorded an + * explicit "none", or gave every current social a verdict with at least + * one confirmed. Verdicts for socials no longer on the artist (e.g. + * replaced via a fix) are ignored. + */ +export function isArtistSocialsResolved( + verification: ArtistSocialsVerification | undefined, + socialIds: string[], +): boolean { + if (!verification) return false; + if (verification.none) return true; + if (socialIds.length === 0) return false; + + const verdicts = socialIds.map((id) => verification.verdicts[id]); + const allDecided = verdicts.every((verdict) => verdict !== undefined); + const anyConfirmed = verdicts.some((verdict) => verdict === "confirmed"); + return allDecided && anyConfirmed; +} diff --git a/lib/onboarding/markArtistHasNoSocials.ts b/lib/onboarding/markArtistHasNoSocials.ts new file mode 100644 index 000000000..c797c932c --- /dev/null +++ b/lib/onboarding/markArtistHasNoSocials.ts @@ -0,0 +1,15 @@ +import type { SocialsVerificationState } from "./socialVerificationTypes"; + +/** + * Immutably records an explicit "this artist has no socials". Resets any + * per-social verdicts — the explicit none supersedes them. + */ +export function markArtistHasNoSocials( + state: SocialsVerificationState, + artistId: string, +): SocialsVerificationState { + return { + ...state, + [artistId]: { verdicts: {}, none: true }, + }; +} diff --git a/lib/onboarding/normalizeSocialPlatform.ts b/lib/onboarding/normalizeSocialPlatform.ts new file mode 100644 index 000000000..914436765 --- /dev/null +++ b/lib/onboarding/normalizeSocialPlatform.ts @@ -0,0 +1,9 @@ +/** + * Chat's legacy link detector spells Apple Music as "APPPLE" while the + * Recoup API expects "APPLE" (its own detector and the PATCH profileUrls + * keys both use the correct spelling). Normalize before comparing or + * sending platform keys to the API. + */ +export function normalizeSocialPlatform(platform: string): string { + return platform === "APPPLE" ? "APPLE" : platform; +} diff --git a/lib/onboarding/socialVerificationTypes.ts b/lib/onboarding/socialVerificationTypes.ts new file mode 100644 index 000000000..9a42237a0 --- /dev/null +++ b/lib/onboarding/socialVerificationTypes.ts @@ -0,0 +1,21 @@ +/** + * Client-side confirmation state for the socials verification onboarding + * step. Deliberately ephemeral: there is no server-side "verified" flag — + * confirming keeps the auto-matched social, fixing replaces it via the + * existing PATCH /api/artists/{id} profileUrls path, and an explicit + * "none" lets an artist with no (correct) socials pass the step. + */ +export type SocialVerdict = "confirmed" | "rejected"; + +export interface ArtistSocialsVerification { + /** Verdict per social id (`socials.id`) on the artist. */ + verdicts: Record; + /** Explicit "this artist has no socials" — supersedes verdicts. */ + none: boolean; +} + +/** Keyed by artist account id. */ +export type SocialsVerificationState = Record< + string, + ArtistSocialsVerification +>; From 65cd47a12fd5bfe7ed620a254eb89551ad8f08f6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 21 Jul 2026 13:25:48 -0500 Subject: [PATCH 2/3] refactor(onboarding): simplify verify-socials to accept-by-default (chat#1867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design review: drop the per-social Correct/Wrong verdicts, the resolution/gating machinery, and the explicit "This artist has no socials" button. Matches are accepted by default; the only actions are Edit (fix a wrong link, existing PATCH profileUrls path) and — for an artist with no matches — a soft nudge + "Add a profile". Continue always proceeds (empty = implicit none). Deletes the verdict code that would otherwise be merged and immediately removed: socialVerificationTypes, applySocialVerdict, isArtistSocialsResolved, areAllArtistsResolved, markArtistHasNoSocials, findSocialIdByPlatform, useSocialsVerification, SocialVerifyRow (+ their tests). Net −13 files. Note: "Remove/delete a social" (the other half of the design) needs a new api endpoint — the api's PATCH profileUrls is per-platform merge with no delete-social path — tracked as a fast-follow on chat#1867. Full suite 144 pass, tsc clean on touched files, lint + prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/Onboarding/ArtistSocialsCard.tsx | 100 ++++++++---------- components/Onboarding/SocialRow.tsx | 79 ++++++++++++++ components/Onboarding/SocialVerifyRow.tsx | 72 ------------- components/Onboarding/VerifySocialsStep.tsx | 36 ++----- hooks/onboarding/useSocialFix.ts | 19 +--- hooks/onboarding/useSocialsVerification.ts | 57 ---------- .../__tests__/applySocialVerdict.test.ts | 59 ----------- .../__tests__/areAllArtistsResolved.test.ts | 32 ------ .../__tests__/findSocialIdByPlatform.test.ts | 30 ------ .../__tests__/isArtistSocialsResolved.test.ts | 57 ---------- .../__tests__/markArtistHasNoSocials.test.ts | 40 ------- lib/onboarding/applySocialVerdict.ts | 25 ----- lib/onboarding/areAllArtistsResolved.ts | 21 ---- lib/onboarding/findSocialIdByPlatform.ts | 18 ---- lib/onboarding/isArtistSocialsResolved.ts | 21 ---- lib/onboarding/markArtistHasNoSocials.ts | 15 --- lib/onboarding/socialVerificationTypes.ts | 21 ---- 17 files changed, 137 insertions(+), 565 deletions(-) create mode 100644 components/Onboarding/SocialRow.tsx delete mode 100644 components/Onboarding/SocialVerifyRow.tsx delete mode 100644 hooks/onboarding/useSocialsVerification.ts delete mode 100644 lib/onboarding/__tests__/applySocialVerdict.test.ts delete mode 100644 lib/onboarding/__tests__/areAllArtistsResolved.test.ts delete mode 100644 lib/onboarding/__tests__/findSocialIdByPlatform.test.ts delete mode 100644 lib/onboarding/__tests__/isArtistSocialsResolved.test.ts delete mode 100644 lib/onboarding/__tests__/markArtistHasNoSocials.test.ts delete mode 100644 lib/onboarding/applySocialVerdict.ts delete mode 100644 lib/onboarding/areAllArtistsResolved.ts delete mode 100644 lib/onboarding/findSocialIdByPlatform.ts delete mode 100644 lib/onboarding/isArtistSocialsResolved.ts delete mode 100644 lib/onboarding/markArtistHasNoSocials.ts delete mode 100644 lib/onboarding/socialVerificationTypes.ts diff --git a/components/Onboarding/ArtistSocialsCard.tsx b/components/Onboarding/ArtistSocialsCard.tsx index 3d4c9a7b9..f6b61d40b 100644 --- a/components/Onboarding/ArtistSocialsCard.tsx +++ b/components/Onboarding/ArtistSocialsCard.tsx @@ -1,91 +1,79 @@ "use client"; -import { CheckCircle2 } from "lucide-react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; -import type { ArtistSocialsVerification } from "@/lib/onboarding/socialVerificationTypes"; import type { ArtistRecord } from "@/types/Artist"; import SocialFixForm from "./SocialFixForm"; -import SocialVerifyRow from "./SocialVerifyRow"; +import SocialRow from "./SocialRow"; interface ArtistSocialsCardProps { artist: ArtistRecord; - verification: ArtistSocialsVerification | undefined; - isResolved: boolean; isFixing: boolean; - onConfirm: (socialId: string) => void; - onReject: (socialId: string) => void; - onMarkNone: () => void; onFix: (url: string) => Promise; } -/** Per-artist socials verification: confirm-or-fix each match, or record "none". */ +/** + * Per-artist socials, accepted by default: each auto-matched profile is + * shown with an Edit affordance to fix a wrong match; an artist with no + * matches gets a soft nudge to add one. No confirm/none step — the step + * proceeds regardless (see VerifySocialsStep). + */ const ArtistSocialsCard = ({ artist, - verification, - isResolved, isFixing, - onConfirm, - onReject, - onMarkNone, onFix, }: ArtistSocialsCardProps) => { const socials = artist.account_socials ?? []; - const hasRejection = Object.values(verification?.verdicts ?? {}).includes( - "rejected", - ); - const showFixForm = hasRejection || socials.length === 0; + const [adding, setAdding] = useState(false); + + const handleAdd = async (url: string) => { + const saved = await onFix(url); + if (saved) setAdding(false); + return saved; + }; return (
-
-

- {artist.name || "Untitled artist"} -

- {isResolved && ( - - - {verification?.none ? "No socials" : "Verified"} - - )} -
+

+ {artist.name || "Untitled artist"} +

{socials.length === 0 ? ( -

- No socials were auto-matched for this artist. -

+ <> +

+ No profiles were auto-matched for {artist.name || "this artist"}. + Add one, or continue. +

+ {adding ? ( + + ) : ( + + )} + ) : (
{socials.map((social) => ( - onConfirm(social.id)} - onReject={() => onReject(social.id)} + isSubmitting={isFixing} + onFix={onFix} /> ))}
)} - - {showFixForm && ( - - )} - - {!verification?.none && ( - - )}
); }; diff --git a/components/Onboarding/SocialRow.tsx b/components/Onboarding/SocialRow.tsx new file mode 100644 index 000000000..ab70bbd66 --- /dev/null +++ b/components/Onboarding/SocialRow.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; +import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount"; +import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; +import formatFollowerCount from "@/lib/utils/formatFollowerCount"; +import SocialFixForm from "./SocialFixForm"; +import type { SOCIAL } from "@/types/Agent"; + +interface SocialRowProps { + social: SOCIAL; + isSubmitting: boolean; + onFix: (url: string) => Promise; +} + +/** + * One auto-matched social, accepted by default: platform · handle · + * followers, with an Edit affordance that reveals a paste-the-correct-link + * form for the rare wrong match. No confirm step — leaving it as-is is the + * confirmation. + */ +const SocialRow = ({ social, isSubmitting, onFix }: SocialRowProps) => { + const [editing, setEditing] = useState(false); + const platform = getPlatformDisplayName( + getSocialPlatformByLink(social.link || ""), + ); + const followerCount = getSocialFollowerCount(social); + const handle = social.username + ? social.username.startsWith("@") + ? social.username + : `@${social.username}` + : social.link; + + const handleFix = async (url: string) => { + const saved = await onFix(url); + if (saved) setEditing(false); + return saved; + }; + + return ( +
+
+
+

{platform}

+

+ {handle} + {followerCount !== null && + ` · ${formatFollowerCount(followerCount)} followers`} +

+
+ +
+ {editing && ( +
+ +
+ )} +
+ ); +}; + +export default SocialRow; diff --git a/components/Onboarding/SocialVerifyRow.tsx b/components/Onboarding/SocialVerifyRow.tsx deleted file mode 100644 index 63ca3eee9..000000000 --- a/components/Onboarding/SocialVerifyRow.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { Check, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import getSocialPlatformByLink from "@/lib/getSocialPlatformByLink"; -import { getSocialFollowerCount } from "@/lib/onboarding/getSocialFollowerCount"; -import getPlatformDisplayName from "@/lib/socials/getPlatformDisplayName"; -import formatFollowerCount from "@/lib/utils/formatFollowerCount"; -import type { SocialVerdict } from "@/lib/onboarding/socialVerificationTypes"; -import type { SOCIAL } from "@/types/Agent"; - -interface SocialVerifyRowProps { - social: SOCIAL; - verdict: SocialVerdict | undefined; - onConfirm: () => void; - onReject: () => void; -} - -/** One auto-matched social: platform, handle, followers, confirm-or-reject. */ -const SocialVerifyRow = ({ - social, - verdict, - onConfirm, - onReject, -}: SocialVerifyRowProps) => { - const platform = getPlatformDisplayName( - getSocialPlatformByLink(social.link || ""), - ); - const followerCount = getSocialFollowerCount(social); - const handle = social.username - ? social.username.startsWith("@") - ? social.username - : `@${social.username}` - : social.link; - - return ( -
-
-

{platform}

-

- {handle} - {followerCount !== null && - ` · ${formatFollowerCount(followerCount)} followers`} -

-
-
- - -
-
- ); -}; - -export default SocialVerifyRow; diff --git a/components/Onboarding/VerifySocialsStep.tsx b/components/Onboarding/VerifySocialsStep.tsx index 7a36adb27..bef078593 100644 --- a/components/Onboarding/VerifySocialsStep.tsx +++ b/components/Onboarding/VerifySocialsStep.tsx @@ -2,23 +2,19 @@ import { Button } from "@/components/ui/button"; import { useSocialFix } from "@/hooks/onboarding/useSocialFix"; -import { useSocialsVerification } from "@/hooks/onboarding/useSocialsVerification"; import { useArtistProvider } from "@/providers/ArtistProvider"; import ArtistSocialsCard from "./ArtistSocialsCard"; /** - * Onboarding step: verify the auto-matched socials for every rostered - * artist. Each match is confirmed or fixed (wrong auto-matches are a - * known failure mode); artists without socials record an explicit none. + * Onboarding step: review the auto-matched socials for every rostered + * artist. Matches are accepted by default — edit any that point at the + * wrong account, or add one where none were found. Continue always + * proceeds; an artist with no socials simply has none. */ const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => { const { sorted } = useArtistProvider(); const artists = sorted.filter((artist) => !artist.isWorkspace); - const { state, setVerdict, markNone, isResolved, allResolved } = - useSocialsVerification(artists); - const { fixSocial, fixingArtistId } = useSocialFix((artistId, socialId) => - setVerdict(artistId, socialId, "confirmed"), - ); + const { fixSocial, fixingArtistId } = useSocialFix(); return (
@@ -27,8 +23,8 @@ const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => { Verify socials

- We auto-matched these profiles. Confirm each one, or fix any that - point at the wrong account — reports and tasks pull from them. + These are the profiles we matched. Fix any that point at the wrong + account — reports and tasks pull from them.

@@ -37,28 +33,14 @@ const VerifySocialsStep = ({ onConfirmed }: { onConfirmed: () => void }) => { - setVerdict(artist.account_id, socialId, "confirmed") - } - onReject={(socialId) => - setVerdict(artist.account_id, socialId, "rejected") - } - onMarkNone={() => markNone(artist.account_id)} onFix={(url) => fixSocial(artist, url)} /> ))} -
); diff --git a/hooks/onboarding/useSocialFix.ts b/hooks/onboarding/useSocialFix.ts index 939e0007e..f7865cdef 100644 --- a/hooks/onboarding/useSocialFix.ts +++ b/hooks/onboarding/useSocialFix.ts @@ -5,19 +5,17 @@ import { usePrivy } from "@privy-io/react-auth"; import { toast } from "sonner"; import saveArtist from "@/lib/saveArtist"; import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload"; -import { findSocialIdByPlatform } from "@/lib/onboarding/findSocialIdByPlatform"; import { useArtistProvider } from "@/providers/ArtistProvider"; import type { ArtistRecord } from "@/types/Artist"; /** * Replaces a wrong auto-matched social (or adds a missing one) with a * corrected profile URL via the existing PATCH /api/artists/{id} - * `profileUrls` path, then refreshes the roster and reports the - * replacement social id so the caller can auto-confirm it. + * `profileUrls` path, then refreshes the roster. Matches are accepted by + * default, so there is no verdict to record — the refreshed roster is the + * source of truth for what's shown. */ -export function useSocialFix( - onFixed: (artistId: string, socialId: string) => void, -) { +export function useSocialFix() { const { getAccessToken } = usePrivy(); const { getArtists } = useArtistProvider(); const [fixingArtistId, setFixingArtistId] = useState(null); @@ -39,16 +37,9 @@ export function useSocialFix( if (!accessToken) { throw new Error("Please sign in to update socials"); } - const data = await saveArtist(accessToken, artist.account_id, { + await saveArtist(accessToken, artist.account_id, { profileUrls: payload.profileUrls, }); - const socialId = findSocialIdByPlatform( - data.artist?.account_socials ?? [], - payload.platform, - ); - if (socialId) { - onFixed(artist.account_id, socialId); - } await getArtists(); return true; } catch (error) { diff --git a/hooks/onboarding/useSocialsVerification.ts b/hooks/onboarding/useSocialsVerification.ts deleted file mode 100644 index b467d2325..000000000 --- a/hooks/onboarding/useSocialsVerification.ts +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; -import { applySocialVerdict } from "@/lib/onboarding/applySocialVerdict"; -import { areAllArtistsResolved } from "@/lib/onboarding/areAllArtistsResolved"; -import { isArtistSocialsResolved } from "@/lib/onboarding/isArtistSocialsResolved"; -import { markArtistHasNoSocials } from "@/lib/onboarding/markArtistHasNoSocials"; -import type { - SocialsVerificationState, - SocialVerdict, -} from "@/lib/onboarding/socialVerificationTypes"; -import type { ArtistRecord } from "@/types/Artist"; - -/** - * Owns the confirm/reject/none verdict state for the socials - * verification step, plus the per-artist and step-level resolution - * predicates derived from the live roster. - */ -export function useSocialsVerification(artists: ArtistRecord[]) { - const [state, setState] = useState({}); - - const setVerdict = useCallback( - (artistId: string, socialId: string, verdict: SocialVerdict) => { - setState((prev) => applySocialVerdict(prev, artistId, socialId, verdict)); - }, - [], - ); - - const markNone = useCallback((artistId: string) => { - setState((prev) => markArtistHasNoSocials(prev, artistId)); - }, []); - - const artistSocialIds = useMemo( - () => - artists.map((artist) => ({ - artistId: artist.account_id, - socialIds: (artist.account_socials ?? []).map((social) => social.id), - })), - [artists], - ); - - const allResolved = useMemo( - () => areAllArtistsResolved(state, artistSocialIds), - [state, artistSocialIds], - ); - - const isResolved = useCallback( - (artist: ArtistRecord) => - isArtistSocialsResolved( - state[artist.account_id], - (artist.account_socials ?? []).map((social) => social.id), - ), - [state], - ); - - return { state, setVerdict, markNone, isResolved, allResolved }; -} diff --git a/lib/onboarding/__tests__/applySocialVerdict.test.ts b/lib/onboarding/__tests__/applySocialVerdict.test.ts deleted file mode 100644 index 746693bdb..000000000 --- a/lib/onboarding/__tests__/applySocialVerdict.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applySocialVerdict } from "@/lib/onboarding/applySocialVerdict"; -import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; - -describe("applySocialVerdict", () => { - it("records a confirmed verdict for a new artist", () => { - const next = applySocialVerdict({}, "artist-1", "social-1", "confirmed"); - expect(next["artist-1"]).toEqual({ - verdicts: { "social-1": "confirmed" }, - none: false, - }); - }); - - it("records a rejected verdict alongside existing verdicts", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: { "social-1": "confirmed" }, none: false }, - }; - const next = applySocialVerdict(state, "artist-1", "social-2", "rejected"); - expect(next["artist-1"].verdicts).toEqual({ - "social-1": "confirmed", - "social-2": "rejected", - }); - }); - - it("overwrites a previous verdict for the same social", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: { "social-1": "rejected" }, none: false }, - }; - const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); - expect(next["artist-1"].verdicts["social-1"]).toBe("confirmed"); - }); - - it("clears an explicit none flag when any verdict is applied", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: {}, none: true }, - }; - const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); - expect(next["artist-1"].none).toBe(false); - }); - - it("does not mutate the input state", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: {}, none: false }, - }; - applySocialVerdict(state, "artist-1", "social-1", "confirmed"); - expect(state["artist-1"].verdicts).toEqual({}); - }); - - it("leaves other artists untouched", () => { - const state: SocialsVerificationState = { - "artist-2": { verdicts: { s: "rejected" }, none: false }, - }; - const next = applySocialVerdict(state, "artist-1", "social-1", "confirmed"); - expect(next["artist-2"]).toEqual({ - verdicts: { s: "rejected" }, - none: false, - }); - }); -}); diff --git a/lib/onboarding/__tests__/areAllArtistsResolved.test.ts b/lib/onboarding/__tests__/areAllArtistsResolved.test.ts deleted file mode 100644 index 972844fce..000000000 --- a/lib/onboarding/__tests__/areAllArtistsResolved.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { areAllArtistsResolved } from "@/lib/onboarding/areAllArtistsResolved"; -import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; - -describe("areAllArtistsResolved", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: { s1: "confirmed" }, none: false }, - "artist-2": { verdicts: {}, none: true }, - }; - - it("is true when every artist is resolved", () => { - expect( - areAllArtistsResolved(state, [ - { artistId: "artist-1", socialIds: ["s1"] }, - { artistId: "artist-2", socialIds: [] }, - ]), - ).toBe(true); - }); - - it("is false when any artist is unresolved", () => { - expect( - areAllArtistsResolved(state, [ - { artistId: "artist-1", socialIds: ["s1"] }, - { artistId: "artist-3", socialIds: ["s9"] }, - ]), - ).toBe(false); - }); - - it("is vacuously true for an empty artist list", () => { - expect(areAllArtistsResolved({}, [])).toBe(true); - }); -}); diff --git a/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts b/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts deleted file mode 100644 index 11cf46bfd..000000000 --- a/lib/onboarding/__tests__/findSocialIdByPlatform.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { findSocialIdByPlatform } from "@/lib/onboarding/findSocialIdByPlatform"; -import type { SOCIAL } from "@/types/Agent"; - -const social = (id: string, type: string, link: string): SOCIAL => - ({ id, type, link }) as SOCIAL; - -describe("findSocialIdByPlatform", () => { - const socials = [ - social("s-ig", "INSTAGRAM", "https://instagram.com/a"), - social("s-tw", "TWITTER", "https://x.com/a"), - ]; - - it("finds the social id for a platform", () => { - expect(findSocialIdByPlatform(socials, "TWITTER")).toBe("s-tw"); - }); - - it("returns null when no social matches", () => { - expect(findSocialIdByPlatform(socials, "TIKTOK")).toBeNull(); - }); - - it("matches APPLE against the legacy APPPLE type spelling", () => { - const apple = [social("s-ap", "APPPLE", "https://music.apple.com/a")]; - expect(findSocialIdByPlatform(apple, "APPLE")).toBe("s-ap"); - }); - - it("returns null for an empty list", () => { - expect(findSocialIdByPlatform([], "INSTAGRAM")).toBeNull(); - }); -}); diff --git a/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts b/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts deleted file mode 100644 index dcc27879e..000000000 --- a/lib/onboarding/__tests__/isArtistSocialsResolved.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isArtistSocialsResolved } from "@/lib/onboarding/isArtistSocialsResolved"; - -describe("isArtistSocialsResolved", () => { - it("is false when the artist has no verification entry", () => { - expect(isArtistSocialsResolved(undefined, ["s1"])).toBe(false); - }); - - it("is false for an artist with zero socials until none is explicit", () => { - expect(isArtistSocialsResolved(undefined, [])).toBe(false); - expect(isArtistSocialsResolved({ verdicts: {}, none: true }, [])).toBe( - true, - ); - }); - - it("is true when explicit none is recorded regardless of socials", () => { - expect( - isArtistSocialsResolved({ verdicts: {}, none: true }, ["s1", "s2"]), - ).toBe(true); - }); - - it("is false while any social lacks a verdict", () => { - expect( - isArtistSocialsResolved({ verdicts: { s1: "confirmed" }, none: false }, [ - "s1", - "s2", - ]), - ).toBe(false); - }); - - it("is false when every verdict is rejected (needs explicit none)", () => { - expect( - isArtistSocialsResolved( - { verdicts: { s1: "rejected", s2: "rejected" }, none: false }, - ["s1", "s2"], - ), - ).toBe(false); - }); - - it("is true when all socials have verdicts and at least one is confirmed", () => { - expect( - isArtistSocialsResolved( - { verdicts: { s1: "confirmed", s2: "rejected" }, none: false }, - ["s1", "s2"], - ), - ).toBe(true); - }); - - it("ignores stale verdicts for socials no longer on the artist", () => { - expect( - isArtistSocialsResolved( - { verdicts: { gone: "confirmed" }, none: false }, - ["s1"], - ), - ).toBe(false); - }); -}); diff --git a/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts b/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts deleted file mode 100644 index d863e04ee..000000000 --- a/lib/onboarding/__tests__/markArtistHasNoSocials.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { markArtistHasNoSocials } from "@/lib/onboarding/markArtistHasNoSocials"; -import type { SocialsVerificationState } from "@/lib/onboarding/socialVerificationTypes"; - -describe("markArtistHasNoSocials", () => { - it("sets the explicit none flag for an unseen artist", () => { - const next = markArtistHasNoSocials({}, "artist-1"); - expect(next["artist-1"]).toEqual({ verdicts: {}, none: true }); - }); - - it("resets any existing verdicts — none supersedes them", () => { - const state: SocialsVerificationState = { - "artist-1": { - verdicts: { "social-1": "confirmed", "social-2": "rejected" }, - none: false, - }, - }; - const next = markArtistHasNoSocials(state, "artist-1"); - expect(next["artist-1"]).toEqual({ verdicts: {}, none: true }); - }); - - it("does not mutate the input state", () => { - const state: SocialsVerificationState = { - "artist-1": { verdicts: { s: "confirmed" }, none: false }, - }; - markArtistHasNoSocials(state, "artist-1"); - expect(state["artist-1"].none).toBe(false); - }); - - it("leaves other artists untouched", () => { - const state: SocialsVerificationState = { - "artist-2": { verdicts: { s: "confirmed" }, none: false }, - }; - const next = markArtistHasNoSocials(state, "artist-1"); - expect(next["artist-2"]).toEqual({ - verdicts: { s: "confirmed" }, - none: false, - }); - }); -}); diff --git a/lib/onboarding/applySocialVerdict.ts b/lib/onboarding/applySocialVerdict.ts deleted file mode 100644 index 7540f6197..000000000 --- a/lib/onboarding/applySocialVerdict.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - SocialsVerificationState, - SocialVerdict, -} from "./socialVerificationTypes"; - -/** - * Immutably records a confirm/reject verdict for one social on one artist. - * Any verdict clears a previous explicit "none" — the user is engaging - * with the matched socials again. - */ -export function applySocialVerdict( - state: SocialsVerificationState, - artistId: string, - socialId: string, - verdict: SocialVerdict, -): SocialsVerificationState { - const current = state[artistId] ?? { verdicts: {}, none: false }; - return { - ...state, - [artistId]: { - verdicts: { ...current.verdicts, [socialId]: verdict }, - none: false, - }, - }; -} diff --git a/lib/onboarding/areAllArtistsResolved.ts b/lib/onboarding/areAllArtistsResolved.ts deleted file mode 100644 index a2741c677..000000000 --- a/lib/onboarding/areAllArtistsResolved.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isArtistSocialsResolved } from "./isArtistSocialsResolved"; -import type { SocialsVerificationState } from "./socialVerificationTypes"; - -export interface ArtistSocialIds { - artistId: string; - socialIds: string[]; -} - -/** - * The socials verification step completes when every rostered artist is - * resolved (see `isArtistSocialsResolved`). Vacuously true for an empty - * roster — callers gate the step on having at least one artist. - */ -export function areAllArtistsResolved( - state: SocialsVerificationState, - artists: ArtistSocialIds[], -): boolean { - return artists.every(({ artistId, socialIds }) => - isArtistSocialsResolved(state[artistId], socialIds), - ); -} diff --git a/lib/onboarding/findSocialIdByPlatform.ts b/lib/onboarding/findSocialIdByPlatform.ts deleted file mode 100644 index e53eb0f01..000000000 --- a/lib/onboarding/findSocialIdByPlatform.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { SOCIAL } from "@/types/Agent"; -import { normalizeSocialPlatform } from "./normalizeSocialPlatform"; - -/** - * Finds the id of an artist's social for a platform key. Used after a - * fix PATCH to locate the replacement social in the refreshed roster so - * it can be auto-confirmed. Tolerates the legacy "APPPLE" spelling. - */ -export function findSocialIdByPlatform( - socials: SOCIAL[], - platform: string, -): string | null { - const target = normalizeSocialPlatform(platform); - const match = socials.find( - (social) => normalizeSocialPlatform(social.type) === target, - ); - return match?.id ?? null; -} diff --git a/lib/onboarding/isArtistSocialsResolved.ts b/lib/onboarding/isArtistSocialsResolved.ts deleted file mode 100644 index c932fc3be..000000000 --- a/lib/onboarding/isArtistSocialsResolved.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ArtistSocialsVerification } from "./socialVerificationTypes"; - -/** - * An artist's socials step is resolved when the user either recorded an - * explicit "none", or gave every current social a verdict with at least - * one confirmed. Verdicts for socials no longer on the artist (e.g. - * replaced via a fix) are ignored. - */ -export function isArtistSocialsResolved( - verification: ArtistSocialsVerification | undefined, - socialIds: string[], -): boolean { - if (!verification) return false; - if (verification.none) return true; - if (socialIds.length === 0) return false; - - const verdicts = socialIds.map((id) => verification.verdicts[id]); - const allDecided = verdicts.every((verdict) => verdict !== undefined); - const anyConfirmed = verdicts.some((verdict) => verdict === "confirmed"); - return allDecided && anyConfirmed; -} diff --git a/lib/onboarding/markArtistHasNoSocials.ts b/lib/onboarding/markArtistHasNoSocials.ts deleted file mode 100644 index c797c932c..000000000 --- a/lib/onboarding/markArtistHasNoSocials.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SocialsVerificationState } from "./socialVerificationTypes"; - -/** - * Immutably records an explicit "this artist has no socials". Resets any - * per-social verdicts — the explicit none supersedes them. - */ -export function markArtistHasNoSocials( - state: SocialsVerificationState, - artistId: string, -): SocialsVerificationState { - return { - ...state, - [artistId]: { verdicts: {}, none: true }, - }; -} diff --git a/lib/onboarding/socialVerificationTypes.ts b/lib/onboarding/socialVerificationTypes.ts deleted file mode 100644 index 9a42237a0..000000000 --- a/lib/onboarding/socialVerificationTypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Client-side confirmation state for the socials verification onboarding - * step. Deliberately ephemeral: there is no server-side "verified" flag — - * confirming keeps the auto-matched social, fixing replaces it via the - * existing PATCH /api/artists/{id} profileUrls path, and an explicit - * "none" lets an artist with no (correct) socials pass the step. - */ -export type SocialVerdict = "confirmed" | "rejected"; - -export interface ArtistSocialsVerification { - /** Verdict per social id (`socials.id`) on the artist. */ - verdicts: Record; - /** Explicit "this artist has no socials" — supersedes verdicts. */ - none: boolean; -} - -/** Keyed by artist account id. */ -export type SocialsVerificationState = Record< - string, - ArtistSocialsVerification ->; From 80d8734a8edacb6a02e538d1c26b8f0dda7d089c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 21 Jul 2026 14:38:58 -0500 Subject: [PATCH 3/3] fix(onboarding): always offer Add-a-profile per artist, not just empty ones A matched-social list can still be missing platforms (e.g. Spotify found but no Instagram). Surface 'Add a profile' below every artist's socials, not only when zero were auto-matched. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/Onboarding/ArtistSocialsCard.tsx | 46 ++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/components/Onboarding/ArtistSocialsCard.tsx b/components/Onboarding/ArtistSocialsCard.tsx index f6b61d40b..aa0cccd13 100644 --- a/components/Onboarding/ArtistSocialsCard.tsx +++ b/components/Onboarding/ArtistSocialsCard.tsx @@ -39,29 +39,10 @@ const ArtistSocialsCard = ({ {socials.length === 0 ? ( - <> -

- No profiles were auto-matched for {artist.name || "this artist"}. - Add one, or continue. -

- {adding ? ( - - ) : ( - - )} - +

+ No profiles were auto-matched for {artist.name || "this artist"}. Add + one, or continue. +

) : (
{socials.map((social) => ( @@ -74,6 +55,25 @@ const ArtistSocialsCard = ({ ))}
)} + + {/* Always available: a matched-social list can still be missing platforms. */} + {adding ? ( + + ) : ( + + )} ); };