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..aa0cccd13 --- /dev/null +++ b/components/Onboarding/ArtistSocialsCard.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { ArtistRecord } from "@/types/Artist"; +import SocialFixForm from "./SocialFixForm"; +import SocialRow from "./SocialRow"; + +interface ArtistSocialsCardProps { + artist: ArtistRecord; + isFixing: boolean; + onFix: (url: string) => Promise; +} + +/** + * 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, + isFixing, + onFix, +}: ArtistSocialsCardProps) => { + const socials = artist.account_socials ?? []; + 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"} +

+ + {socials.length === 0 ? ( +

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

+ ) : ( +
+ {socials.map((social) => ( + + ))} +
+ )} + + {/* Always available: a matched-social list can still be missing platforms. */} + {adding ? ( + + ) : ( + + )} +
+ ); +}; + +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/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/VerifySocialsStep.tsx b/components/Onboarding/VerifySocialsStep.tsx new file mode 100644 index 000000000..bef078593 --- /dev/null +++ b/components/Onboarding/VerifySocialsStep.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useSocialFix } from "@/hooks/onboarding/useSocialFix"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import ArtistSocialsCard from "./ArtistSocialsCard"; + +/** + * 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 { fixSocial, fixingArtistId } = useSocialFix(); + + return ( +
+
+

+ Verify socials +

+

+ These are the profiles we matched. Fix any that point at the wrong + account — reports and tasks pull from them. +

+
+ +
+ {artists.map((artist) => ( + 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..f7865cdef --- /dev/null +++ b/hooks/onboarding/useSocialFix.ts @@ -0,0 +1,56 @@ +"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 { 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. 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() { + 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"); + } + await saveArtist(accessToken, artist.account_id, { + profileUrls: payload.profileUrls, + }); + 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/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__/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__/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/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/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/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; +}