Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/onboarding/roster/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import RosterSocialsFlow from "@/components/Onboarding/RosterSocialsFlow";

const OnboardingRoster = () => <RosterSocialsFlow />;

export default OnboardingRoster;
63 changes: 63 additions & 0 deletions components/Onboarding/AddArtistForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setIsOpen(true)}
>
<Plus className="size-4 mr-2" />
Add another artist
</Button>
);
}

return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Artist name"
disabled={isAdding}
aria-label="Artist name"
/>
<Button type="submit" disabled={isAdding || !name.trim()}>
{isAdding ? <Loader className="size-4 animate-spin" /> : "Add"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: While submission is pending, the button has no accessible name because Add is replaced by the spinner icon. Keep an accessible loading label so screen-reader users receive the operation state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Onboarding/AddArtistForm.tsx, line 49:

<comment>While submission is pending, the button has no accessible name because `Add` is replaced by the spinner icon. Keep an accessible loading label so screen-reader users receive the operation state.</comment>

<file context>
@@ -0,0 +1,63 @@
+        aria-label="Artist name"
+      />
+      <Button type="submit" disabled={isAdding || !name.trim()}>
+        {isAdding ? <Loader className="size-4 animate-spin" /> : "Add"}
+      </Button>
+      <Button
</file context>

</Button>
Comment on lines +48 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep an accessible submit label while loading.

Line 49 replaces “Add” with an icon, leaving the disabled submit control without a reliable accessible name or status.

Proposed fix
-      <Button type="submit" disabled={isAdding || !name.trim()}>
-        {isAdding ? <Loader className="size-4 animate-spin" /> : "Add"}
+      <Button type="submit" disabled={isAdding || !name.trim()}>
+        {isAdding ? (
+          <>
+            <Loader aria-hidden="true" className="size-4 animate-spin" />
+            <span className="sr-only">Adding artist</span>
+          </>
+        ) : (
+          "Add"
+        )}
       </Button>

As per coding guidelines, “Provide clear labels and error messages in form components.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button type="submit" disabled={isAdding || !name.trim()}>
{isAdding ? <Loader className="size-4 animate-spin" /> : "Add"}
</Button>
<Button type="submit" disabled={isAdding || !name.trim()}>
{isAdding ? (
<>
<Loader aria-hidden="true" className="size-4 animate-spin" />
<span className="sr-only">Adding artist</span>
</>
) : (
"Add"
)}
</Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/Onboarding/AddArtistForm.tsx` around lines 48 - 50, The submit
Button in AddArtistForm currently replaces its text label with only a loader
while isAdding; preserve a reliable accessible label and loading status by
retaining or providing text such as “Adding…” alongside the loader, while
keeping the existing disabled behavior.

Source: Coding guidelines

<Button
type="button"
variant="ghost"
disabled={isAdding}
onClick={() => setIsOpen(false)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Keyboard focus is lost when Cancel closes the form because the focused button unmounts without returning focus to the trigger. Restore focus to Add another artist after closing so keyboard users can continue navigation predictably.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Onboarding/AddArtistForm.tsx, line 55:

<comment>Keyboard focus is lost when Cancel closes the form because the focused button unmounts without returning focus to the trigger. Restore focus to `Add another artist` after closing so keyboard users can continue navigation predictably.</comment>

<file context>
@@ -0,0 +1,63 @@
+        type="button"
+        variant="ghost"
+        disabled={isAdding}
+        onClick={() => setIsOpen(false)}
+      >
+        Cancel
</file context>

>
Cancel
</Button>
</form>
);
};

export default AddArtistForm;
81 changes: 81 additions & 0 deletions components/Onboarding/ArtistSocialsCard.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

/**
* 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 (
<div className="p-4 rounded-xl border border-border bg-card flex flex-col gap-2">
<h2 className="font-medium text-card-foreground truncate">
{artist.name || "Untitled artist"}
</h2>

{socials.length === 0 ? (
<p className="text-sm text-muted-foreground">
No profiles were auto-matched for {artist.name || "this artist"}. Add
one, or continue.
</p>
) : (
<div className="divide-y divide-border">
{socials.map((social) => (
<SocialRow
key={social.id}
social={social}
isSubmitting={isFixing}
onFix={onFix}
/>
))}
</div>
)}

{/* Always available: a matched-social list can still be missing platforms. */}
{adding ? (
<SocialFixForm
placeholder="Paste a profile link"
isSubmitting={isFixing}
onSubmit={handleAdd}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => setAdding(true)}
>
Add a profile
</Button>
)}
</div>
);
};

export default ArtistSocialsCard;
62 changes: 62 additions & 0 deletions components/Onboarding/ConfirmRosterStep.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">
Confirm your roster
</h1>
<p className="text-sm text-muted-foreground mt-1">
We set {artists.length === 1 ? "this artist" : "these artists"} up
from your valuation. Add anyone else you manage — you can verify their
socials next.
</p>
</div>

<div className="flex flex-col gap-3">
{isLoading ? (
<>
<Skeleton className="h-[72px] w-full rounded-xl" />
<Skeleton className="h-[72px] w-full rounded-xl" />
</>
) : artists.length === 0 ? (
<p className="text-sm text-muted-foreground p-4 rounded-xl border border-border">
No artists on your roster yet. Add your first artist below.
</p>
) : (
artists.map((artist) => (
<RosterArtistRow key={artist.account_id} artist={artist} />
))
)}
<AddArtistForm />
</div>

<Button
type="button"
className="w-full"
disabled={isLoading || artists.length === 0}
onClick={onConfirmed}
>
{artists.length > 1 ? "These are my artists" : "This is my artist"} —
continue
</Button>
</section>
);
};

export default ConfirmRosterStep;
31 changes: 31 additions & 0 deletions components/Onboarding/RosterArtistRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3 p-4 rounded-xl border border-border bg-card">
<Avatar>
{artist.image ? <AvatarImage src={artist.image} alt="" /> : null}
<AvatarFallback>
{(artist.name || "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-medium text-card-foreground truncate">
{artist.name || "Untitled artist"}
</p>
<p className="text-xs text-muted-foreground">{socialsLabel}</p>
</div>
</div>
);
};

export default RosterArtistRow;
37 changes: 37 additions & 0 deletions components/Onboarding/RosterSocialsFlow.tsx
Original file line number Diff line number Diff line change
@@ -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<FlowStep>("roster");

return (
<div className="w-full max-w-xl mx-auto grow py-8 px-6">
{step !== "done" && (
<p className="text-xs text-muted-foreground mb-6">
Step {step === "roster" ? "1" : "2"} of 2
</p>
)}
{step === "roster" && (
<ConfirmRosterStep onConfirmed={() => setStep("socials")} />
)}
{step === "socials" && (
<VerifySocialsStep onConfirmed={() => setStep("done")} />
)}
{step === "done" && <RosterVerifiedPanel />}
</div>
);
};

export default RosterSocialsFlow;
27 changes: 27 additions & 0 deletions components/Onboarding/RosterVerifiedPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<section className="flex flex-col items-center gap-4 text-center py-12">
<CheckCircle2 className="size-10 text-[#22c55e]" />
<div>
<h1 className="text-2xl font-semibold text-foreground">
Roster verified
</h1>
<p className="text-sm text-muted-foreground mt-1">
Your artists and their socials are confirmed. Reports, research, and
tasks will use them from here on.
</p>
</div>
<Link href="/" className={cn(buttonVariants(), "min-w-[200px]")}>
Back to Recoup
</Link>
</section>
);

export default RosterVerifiedPanel;
44 changes: 44 additions & 0 deletions components/Onboarding/SocialFixForm.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

/** 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("");
};
Comment on lines +22 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The async handleSubmit doesn't catch promise rejections from onSubmit. If a future consumer or bug causes a thrown error, the rejection goes unhandled and isSubmitting (controlled by parent) may never reset. Wrap in a try/catch as a defensive measure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Onboarding/SocialFixForm.tsx, line 22:

<comment>The async `handleSubmit` doesn't catch promise rejections from `onSubmit`. If a future consumer or bug causes a thrown error, the rejection goes unhandled and `isSubmitting` (controlled by parent) may never reset. Wrap in a try/catch as a defensive measure.</comment>

<file context>
@@ -0,0 +1,44 @@
+}: SocialFixFormProps) => {
+  const [url, setUrl] = useState("");
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    const saved = await onSubmit(url);
</file context>
Suggested change
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const saved = await onSubmit(url);
if (saved) setUrl("");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const saved = await onSubmit(url);
if (saved) setUrl("");
} catch {
// Parent handles display; keep input for retry
}
};


return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={placeholder}
disabled={isSubmitting}
aria-label="Profile link"
/>
<Button type="submit" size="sm" disabled={isSubmitting || !url.trim()}>
{isSubmitting ? <Loader className="size-4 animate-spin" /> : "Save"}
</Button>
</form>
);
};

export default SocialFixForm;
Loading
Loading