Skip to content
Open
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
2 changes: 2 additions & 0 deletions components/Home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMiniKit } from "@coinbase/onchainkit/minikit";
import { Chat } from "../VercelChat/chat";
import { useEffect } from "react";
import { UIMessage } from "ai";
import OnboardingModal from "@/components/Onboarding/OnboardingModal";

const HomePage = ({
id,
Expand All @@ -22,6 +23,7 @@ const HomePage = ({

return (
<div className="flex flex-col size-full items-center">
<OnboardingModal />
<Chat id={id} initialMessages={initialMessages} />
</div>
);
Expand Down
200 changes: 200 additions & 0 deletions components/Onboarding/OnboardingArtistsStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"use client";

import { Input } from "@/components/ui/input";
import { X, Music2, Search, Loader2 } from "lucide-react";
import { OnboardingNavButtons } from "./OnboardingNavButtons";
import { getRoleConfig } from "./onboardingRoleConfig";
import { useSpotifyArtistSearch, type SpotifyArtist } from "./useSpotifyArtistSearch";
import { useState } from "react";

export interface ArtistEntry {
name: string;
spotifyUrl?: string;
imageUrl?: string;
}

interface Props {
artists: ArtistEntry[];
onUpdate: (artists: ArtistEntry[]) => void;
onNext: () => void;
onBack: () => void;
roleType?: string;
}

/**
* Artist step — live Spotify search with avatars, manual fallback.
*/
export function OnboardingArtistsStep({ artists, onUpdate, onNext, onBack, roleType }: Props) {
const { query, setQuery, results, searching, clearResults } = useSpotifyArtistSearch();
const [focused, setFocused] = useState(false);

const { artistPlaceholder } = getRoleConfig(roleType);

const addFromSpotify = (a: SpotifyArtist) => {
if (artists.some(x => x.spotifyUrl === a.external_urls.spotify)) return;
onUpdate([
...artists,
{
name: a.name,
spotifyUrl: a.external_urls.spotify,
imageUrl: a.images?.[0]?.url,
},
]);
clearResults();
};

const addManual = () => {
const trimmed = query.trim();
if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return;
onUpdate([...artists, { name: trimmed }]);
clearResults();
};
Comment on lines +33 to +51
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.

⚠️ Potential issue | 🟡 Minor

Unify duplicate detection across manual and Spotify adds.

Right now the two add paths use different rules, so a manually added artist can still be added again from Spotify. This should share one duplicate check before either path mutates the list.

Suggested change
+  const normalizeArtistName = (value: string) => value.trim().toLowerCase();
+  const hasArtist = (name: string, spotifyUrl?: string) =>
+    artists.some(existing =>
+      (spotifyUrl && existing.spotifyUrl === spotifyUrl) ||
+      normalizeArtistName(existing.name) === normalizeArtistName(name),
+    );
+
   const addFromSpotify = (a: SpotifyArtist) => {
-    if (artists.some(x => x.spotifyUrl === a.external_urls.spotify)) return;
+    if (hasArtist(a.name, a.external_urls.spotify)) return;
     onUpdate([
       ...artists,
       {
         name: a.name,
         spotifyUrl: a.external_urls.spotify,
@@
   const addManual = () => {
     const trimmed = query.trim();
-    if (!trimmed || artists.some(x => x.name.toLowerCase() === trimmed.toLowerCase())) return;
+    if (!trimmed || hasArtist(trimmed)) return;
     onUpdate([...artists, { name: trimmed }]);
     clearResults();
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Onboarding/OnboardingArtistsStep.tsx` around lines 35 - 53, Both
addFromSpotify and addManual perform duplicate checks with different rules;
extract a single predicate (e.g., isDuplicateArtist) and use it from both
functions so the same logic decides duplicates before mutating artists.
Implement isDuplicateArtist to accept a candidate object (with optional
spotifyUrl and name) and return true if any existing artist matches by
spotifyUrl when present, otherwise by case-insensitive name; call this predicate
at the start of addFromSpotify and addManual and return early if true, then
proceed to call onUpdate and clearResults.


const remove = (idx: number) => onUpdate(artists.filter((_, i) => i !== idx));

const showDropdown = focused && (results.length > 0 || (searching && query.length > 1));
const showManualAdd = focused && query.trim().length > 1 && !searching && results.length === 0;

return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Add your priority artists</h2>
<p className="mt-1 text-sm text-muted-foreground">
We&apos;ll run deep research — fan data, release windows, competitive benchmarks
— before you ever open a chat.
</p>
</div>

{/* Search input */}
<div className="relative">
<div className="relative flex items-center">
<Search className="absolute left-3 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={artistPlaceholder}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={e => {
if (e.key === "Enter" && results.length > 0) addFromSpotify(results[0]);
else if (e.key === "Enter" && query.trim()) addManual();
}}
className="pl-9 pr-9"
/>
{searching && (
<Loader2 className="absolute right-3 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>

{/* Dropdown */}
{showDropdown && (
<div className="absolute z-50 mt-1 w-full rounded-xl border bg-popover shadow-lg overflow-hidden">
{results.map(a => (
<ArtistSearchResult key={a.id} artist={a} onSelect={addFromSpotify} />
))}
</div>
Comment on lines +69 to +95
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.

⚠️ Potential issue | 🟠 Major

Make this search control a real accessible combobox/listbox.

The field currently has no programmatic label, and keyboard users can only submit the first result with Enter. There’s no owned list state (aria-expanded/aria-controls), active option tracking, or arrow-key navigation through results, so this interaction is effectively mouse-first.

As per coding guidelines, "Ensure keyboard navigation and focus management in UI components" and "Provide proper ARIA roles/states and test with screen readers".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Onboarding/OnboardingArtistsStep.tsx` around lines 71 - 97, The
search input (Input) should be converted to an accessible combobox: add a
programmatic label or aria-label, set role="combobox" with aria-expanded tied to
showDropdown, aria-controls pointing to the results container id, and
aria-activedescendant referencing the active option id; add component state
activeIndex (default -1) and pass highlighted state/id to each
ArtistSearchResult so each option has role="option" and an id; enhance the
onKeyDown handler to handle ArrowDown/ArrowUp/Home/End to move activeIndex (and
open dropdown), Enter to select the highlighted option via
addFromSpotify(results[activeIndex]) (or call addManual when no selection),
Escape to close (setFocused(false)/showDropdown false), and manage focus/blur
timing so clicking an option doesn't immediately close the list (use onMouseDown
on options or preventDefault on blur), ensuring keyboard-only users can navigate
and select results and screen readers get correct ARIA roles and state.

)}

{/* Manual add fallback */}
{showManualAdd && (
<div className="absolute z-50 mt-1 w-full rounded-xl border bg-popover shadow-lg overflow-hidden">
<button
type="button"
onMouseDown={addManual}
className="flex items-center gap-3 w-full px-3 py-2.5 hover:bg-muted transition-colors text-left"
>
<ArtistAvatar />
<div>
<p className="text-sm font-medium">Add &ldquo;{query.trim()}&rdquo;</p>
<p className="text-xs text-muted-foreground">Add manually</p>
</div>
</button>
</div>
)}
</div>

{/* Added artists */}
{artists.length > 0 && (
<ul className="flex flex-col gap-2">
{artists.map((a, i) => (
<li key={i} className="flex items-center gap-3 rounded-xl border bg-muted/20 px-3 py-2.5">
<ArtistAvatar imageUrl={a.imageUrl} name={a.name} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{a.name}</p>
{a.spotifyUrl && (
<p className="text-xs text-muted-foreground">Spotify connected ✓</p>
)}
</div>
<button
type="button"
onClick={() => remove(i)}
className="rounded-md p-1 hover:bg-muted/60 transition-colors shrink-0"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
Comment on lines +128 to +134
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.

⚠️ Potential issue | 🟠 Major

Label the icon-only artist actions.

The remove buttons and the plus button have no accessible name, so screen readers will only announce button. Please add aria-labels here, including the artist name on remove.

As per coding guidelines, "Provide proper ARIA roles/states and test with screen readers" and "Provide clear labels and error messages in form components".

Also applies to: 94-102

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Onboarding/OnboardingArtistsStep.tsx` around lines 72 - 78, The
remove artist icon buttons lack accessible names; update the button that calls
removeArtist(i) to include an aria-label like `Remove artist {artist.name}` (use
the artist variable in scope) so screen readers announce the action and target;
likewise add an aria-label (e.g., "Add artist" or "Add another artist") to the
plus/add button referenced later (the handler that adds an artist). Ensure these
aria-labels are present on the button elements that render the X icon and the
plus icon so assistive tech announces meaningful labels.

</li>
))}
</ul>
)}

<div className="flex flex-col gap-2">
<OnboardingNavButtons
onBack={onBack}
onNext={onNext}
nextDisabled={artists.length === 0}
nextLabel={
artists.length > 0
? `Research ${artists.length} artist${artists.length > 1 ? "s" : ""} →`
: "Add at least one artist"
}
/>
{artists.length === 0 && (
<button
type="button"
onClick={onNext}
className="text-xs text-center text-muted-foreground hover:text-foreground transition-colors"
>
Skip for now — you can add artists later
</button>
)}
</div>
</div>
);
}

/** Small helper components scoped to this file */

function ArtistAvatar({ imageUrl, name }: { imageUrl?: string; name?: string }) {
return imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt={name ?? ""} className="h-9 w-9 rounded-full object-cover shrink-0" />
) : (
<div className="h-9 w-9 rounded-full bg-muted flex items-center justify-center shrink-0">
<Music2 className="h-4 w-4 text-muted-foreground" />
</div>
);
}

function ArtistSearchResult({
artist,
onSelect,
}: {
artist: SpotifyArtist;
onSelect: (a: SpotifyArtist) => void;
}) {
return (
<button
type="button"
onMouseDown={() => onSelect(artist)}
className="flex items-center gap-3 w-full px-3 py-2.5 hover:bg-muted transition-colors text-left"
>
<ArtistAvatar imageUrl={artist.images?.[0]?.url} name={artist.name} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{artist.name}</p>
<p className="text-xs text-muted-foreground">
{artist.followers?.total?.toLocaleString()} followers
</p>
</div>
</button>
);
}
122 changes: 122 additions & 0 deletions components/Onboarding/OnboardingCompleteStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
import { OnboardingConfetti } from "./OnboardingConfetti";

interface Props {
artistNames: string[];
name: string | undefined;
connectedCount: number;
pulseEnabled: boolean;
onComplete: () => void;
}

/**
* The "aha moment" — everything summarized with Framer Motion reveals + confetti.
*/
export function OnboardingCompleteStep({
artistNames,
name,
connectedCount,
pulseEnabled,
onComplete,
}: Props) {
const [visible, setVisible] = useState(false);

useEffect(() => {
const t = setTimeout(() => setVisible(true), 100);
return () => clearTimeout(t);
}, []);

const summaryItems = [
artistNames.length > 0 && {
icon: "🎤",
text: `Deep research running on ${artistNames.slice(0, 2).join(" & ")}${
artistNames.length > 2 ? ` +${artistNames.length - 2} more` : ""
}`,
},
connectedCount > 0 && {
icon: "🔗",
text: `${connectedCount} platform${connectedCount > 1 ? "s" : ""} connected`,
},
pulseEnabled && {
icon: "⚡",
text: "Pulse active — briefing arrives tomorrow morning",
},
{ icon: "✅", text: "First week of tasks queued" },
{ icon: "🧠", text: "AI learning your artists and fans right now" },
].filter(Boolean) as { icon: string; text: string }[];

return (
<AnimatePresence>
{visible && (
<>
<OnboardingConfetti />
<motion.div
className="flex flex-col items-center gap-7 text-center"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Trophy */}
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.1 }}
className="text-6xl"
>
🚀
</motion.div>

<motion.div
className="flex flex-col gap-1.5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
>
<h2 className="text-2xl font-bold tracking-tight leading-tight">
{name ? `${name.split(" ")[0]}, you're already ahead.` : "You're already ahead."}
</h2>
<p className="text-sm text-muted-foreground max-w-xs">
While competitors are guessing, you have AI running intelligence on every move.
</p>
</motion.div>

{/* Summary items */}
<div className="flex flex-col gap-2.5 w-full text-left">
{summaryItems.map((item, i) => (
<motion.div
key={i}
className="flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3 text-sm"
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.35 + i * 0.07 }}
>
<span className="text-lg">{item.icon}</span>
<span className="font-medium">{item.text}</span>
</motion.div>
))}
</div>

<motion.div
className="flex flex-col gap-3 w-full"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
>
<Button onClick={onComplete} className="w-full text-base py-5">
Open my dashboard 🎯
</Button>
<p className="text-xs text-muted-foreground">
Your peers in music will want to know what you&apos;re using.
You don&apos;t have to tell them.
</p>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
Loading
Loading