-
Notifications
You must be signed in to change notification settings - Fork 13
agent: @U0AJM7X8FBR Chat - we want an onboarding flow that gets the user from #1575
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
base: test
Are you sure you want to change the base?
Changes from all commits
d88963c
a11b4c7
50e81ca
1df4f3d
af097bc
c5b468d
55116e5
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,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(); | ||
| }; | ||
|
|
||
| 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'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
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. 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 ( 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 |
||
| )} | ||
|
|
||
| {/* 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 “{query.trim()}”</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
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. Label the icon-only artist actions. The remove buttons and the plus button have no accessible name, so screen readers will only announce 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 |
||
| </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> | ||
| ); | ||
| } | ||
| 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're using. | ||
| You don't have to tell them. | ||
| </p> | ||
| </motion.div> | ||
| </motion.div> | ||
| </> | ||
| )} | ||
| </AnimatePresence> | ||
| ); | ||
| } |
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.
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
🤖 Prompt for AI Agents