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
6 changes: 5 additions & 1 deletion app/api/account/update/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import updateAccountInfo from "@/lib/supabase/account_info/updateAccountInfo";

export async function POST(req: NextRequest) {
const body = await req.json();
const { instruction, name, organization, accountId, image, jobTitle, roleType, companyName, knowledges } = body;
const { instruction, name, organization, accountId, image, jobTitle, roleType, companyName, knowledges, onboardingStatus, onboardingData } = body;

try {
const found = await getAccountById(accountId);
Expand All @@ -26,6 +26,8 @@ export async function POST(req: NextRequest) {
role_type: roleType,
company_name: companyName,
knowledges,
...(onboardingStatus !== undefined && { onboarding_status: onboardingStatus }),
...(onboardingData !== undefined && { onboarding_data: onboardingData }),
});
} else {
await updateAccountInfo(accountId, {
Expand All @@ -36,6 +38,8 @@ export async function POST(req: NextRequest) {
role_type: roleType,
company_name: companyName,
knowledges,
...(onboardingStatus !== undefined && { onboarding_status: onboardingStatus }),
...(onboardingData !== undefined && { onboarding_data: onboardingData }),
});
}

Expand Down
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();
};

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>
)}

{/* 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>
</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>
);
}
131 changes: 131 additions & 0 deletions components/Onboarding/OnboardingCompleteStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"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 | Promise<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);
const [isCompleting, setIsCompleting] = 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={async () => {
if (isCompleting) return;
setIsCompleting(true);
await onComplete();
}}
disabled={isCompleting}
className="w-full text-base py-5"
>
{isCompleting ? "Opening…" : "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