-
Notifications
You must be signed in to change notification settings - Fork 19
feat: roster + socials verification onboarding steps (chat#1867) #1870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7c88616
65cd47a
80d8734
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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"} | ||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||
|
Comment on lines
+48
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||
| variant="ghost" | ||||||||||||||||||||||||||||
| disabled={isAdding} | ||||||||||||||||||||||||||||
| onClick={() => setIsOpen(false)} | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| Cancel | ||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||
| </form> | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export default AddArtistForm; | ||||||||||||||||||||||||||||
| 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; |
| 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; |
| 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; |
| 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; |
| 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; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The async Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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
Addis replaced by the spinner icon. Keep an accessible loading label so screen-reader users receive the operation state.Prompt for AI agents