From 73f82f0ace645bfbea3787d51b6bdde6629cdab8 Mon Sep 17 00:00:00 2001 From: JoesWalker Date: Tue, 28 Apr 2026 11:59:05 +0000 Subject: [PATCH 1/2] feat(social): implement social profiles, following system, and activity feed --- src/components/social/ActivityFeed.tsx | 107 ++++++ src/components/social/FollowingSystem.tsx | 115 ++++++ src/components/social/SocialInteractions.tsx | 121 ++++++ src/components/social/SocialProfile.tsx | 108 ++++++ .../social/__tests__/socialFeatures.test.tsx | 352 ++++++++++++++++++ src/hooks/useSocialFeatures.tsx | 153 ++++++++ src/utils/socialUtils.ts | 54 +++ vitest.config.ts | 1 + 8 files changed, 1011 insertions(+) create mode 100644 src/components/social/ActivityFeed.tsx create mode 100644 src/components/social/FollowingSystem.tsx create mode 100644 src/components/social/SocialInteractions.tsx create mode 100644 src/components/social/SocialProfile.tsx create mode 100644 src/components/social/__tests__/socialFeatures.test.tsx create mode 100644 src/hooks/useSocialFeatures.tsx create mode 100644 src/utils/socialUtils.ts diff --git a/src/components/social/ActivityFeed.tsx b/src/components/social/ActivityFeed.tsx new file mode 100644 index 00000000..b239bbec --- /dev/null +++ b/src/components/social/ActivityFeed.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { UserCircle } from 'lucide-react'; +import { useActivityFeed } from '@/hooks/useSocialFeatures'; +import { getRelativeTime, groupActivitiesByDate } from '@/utils/socialUtils'; + +function Skeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +interface ActivityFeedProps { + userId: string; +} + +export default function ActivityFeed({ userId }: ActivityFeedProps) { + const { activities, loadMore, loading, hasMore } = useActivityFeed(userId); + const sentinelRef = useRef(null); + + // Trigger loadMore when sentinel enters viewport + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) loadMore(); }, + { threshold: 0.1 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [loadMore]); + + const grouped = groupActivitiesByDate(activities); + + return ( +
+
+

Activity

+
+ +
+ {loading && activities.length === 0 && ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ )} + + {Object.entries(grouped).map(([date, items]) => ( +
+

+ {date} +

+ {items.map((activity) => ( +
+ {activity.actorAvatar ? ( + {activity.actorName} + ) : ( + + )} +
+

+ {activity.actorName}{' '} + {activity.action} + {activity.targetTitle && ( + <> {activity.targetTitle} + )} +

+

+ {getRelativeTime(activity.createdAt)} +

+
+
+ ))} +
+ ))} + + {/* Infinite scroll sentinel */} + {hasMore &&
} + + {loading && activities.length > 0 && ( +
+ +
+ )} + + {!loading && !hasMore && activities.length > 0 && ( +

No more activity

+ )} + + {!loading && activities.length === 0 && ( +

No activity yet.

+ )} +
+
+ ); +} diff --git a/src/components/social/FollowingSystem.tsx b/src/components/social/FollowingSystem.tsx new file mode 100644 index 00000000..7744987c --- /dev/null +++ b/src/components/social/FollowingSystem.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Search, UserCircle } from 'lucide-react'; +import { useFollowUser } from '@/hooks/useSocialFeatures'; +import { apiClient } from '@/lib/api'; +import type { SocialUser } from './SocialProfile'; + +interface FollowingSystemProps { + userId: string; +} + +type ListTab = 'followers' | 'following'; + +function UserRow({ user }: { user: SocialUser }) { + const { isFollowing, follow, unfollow, loading } = useFollowUser(user.id); + return ( +
+
+ {user.avatarUrl ? ( + {user.name} + ) : ( + + )} +
+

{user.name}

+ {user.bio && ( +

+ {user.bio} +

+ )} +
+
+ +
+ ); +} + +export default function FollowingSystem({ userId }: FollowingSystemProps) { + const [tab, setTab] = useState('followers'); + const [users, setUsers] = useState([]); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + apiClient + .get(`/api/social/${tab}/${userId}`) + .then(setUsers) + .catch(() => setUsers([])) + .finally(() => setLoading(false)); + }, [tab, userId]); + + const filtered = users.filter((u) => + u.name.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+ {/* Tabs */} +
+ {(['followers', 'following'] as ListTab[]).map((t) => ( + + ))} +
+ + {/* Search */} +
+
+ + setQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg border-0 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white" + /> +
+
+ + {/* List */} +
+ {loading && ( +
+
+
+ )} + {!loading && filtered.length === 0 && ( +

No users found.

+ )} + {!loading && filtered.map((u) => )} +
+
+ ); +} diff --git a/src/components/social/SocialInteractions.tsx b/src/components/social/SocialInteractions.tsx new file mode 100644 index 00000000..a7fdbd27 --- /dev/null +++ b/src/components/social/SocialInteractions.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useState } from 'react'; +import { Heart, MessageCircle, Share2, UserCircle } from 'lucide-react'; +import { useSocialInteractions } from '@/hooks/useSocialFeatures'; +import { formatFollowerCount, getRelativeTime } from '@/utils/socialUtils'; + +interface SocialInteractionsProps { + contentId: string; + contentUrl?: string; +} + +export default function SocialInteractions({ contentId, contentUrl }: SocialInteractionsProps) { + const { likes, liked, comments, toggleLike, addComment, loading } = + useSocialInteractions(contentId); + const [showComments, setShowComments] = useState(false); + const [draft, setDraft] = useState(''); + const [copied, setCopied] = useState(false); + + const handleShare = async () => { + const url = contentUrl ?? window.location.href; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleAddComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!draft.trim()) return; + await addComment(draft.trim()); + setDraft(''); + }; + + return ( +
+ {/* Action bar */} +
+ {/* Like */} + + + {/* Comment toggle */} + + + {/* Share */} + +
+ + {/* Comment section */} + {showComments && ( +
+ {/* Add comment */} +
+ setDraft(e.target.value)} + placeholder="Add a comment…" + className="flex-1 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white" + /> + +
+ + {/* Comment list */} +
+ {comments.map((c) => ( +
+ {c.authorAvatar ? ( + {c.authorName} + ) : ( + + )} +
+

{c.authorName}

+

{c.body}

+

{getRelativeTime(c.createdAt)}

+
+
+ ))} + {comments.length === 0 && ( +

No comments yet.

+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/social/SocialProfile.tsx b/src/components/social/SocialProfile.tsx new file mode 100644 index 00000000..e44e3308 --- /dev/null +++ b/src/components/social/SocialProfile.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; +import { UserCircle } from 'lucide-react'; +import { useFollowUser } from '@/hooks/useSocialFeatures'; +import { formatFollowerCount } from '@/utils/socialUtils'; + +export interface SocialUser { + id: string; + name: string; + bio?: string; + avatarUrl?: string; + followerCount: number; + followingCount: number; +} + +interface SocialProfileProps { + user: SocialUser; + isOwnProfile?: boolean; +} + +type Tab = 'posts' | 'activity' | 'analytics'; + +export default function SocialProfile({ user, isOwnProfile = false }: SocialProfileProps) { + const [activeTab, setActiveTab] = useState('posts'); + const { isFollowing, follow, unfollow, loading } = useFollowUser(user.id); + + const tabs: { key: Tab; label: string }[] = [ + { key: 'posts', label: 'Posts' }, + { key: 'activity', label: 'Activity' }, + { key: 'analytics', label: 'Analytics' }, + ]; + + return ( +
+ {/* Profile header */} +
+ {user.avatarUrl ? ( + {user.name} + ) : ( + + )} + +
+
+

{user.name}

+ {!isOwnProfile && ( + + )} +
+ + {user.bio && ( +

{user.bio}

+ )} + +
+ + {formatFollowerCount(user.followerCount)}{' '} + followers + + + {formatFollowerCount(user.followingCount)}{' '} + following + +
+
+
+ + {/* Tab navigation */} +
+ {tabs.map(({ key, label }) => ( + + ))} +
+ + {/* Tab content placeholder */} +
+ {activeTab === 'posts' &&

Posts will appear here.

} + {activeTab === 'activity' &&

Recent activity will appear here.

} + {activeTab === 'analytics' &&

Analytics will appear here.

} +
+
+ ); +} diff --git a/src/components/social/__tests__/socialFeatures.test.tsx b/src/components/social/__tests__/socialFeatures.test.tsx new file mode 100644 index 00000000..56bce5d8 --- /dev/null +++ b/src/components/social/__tests__/socialFeatures.test.tsx @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderHook } from '@testing-library/react'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), query: {} }), +})); + +// ─── Imports after mocks ─────────────────────────────────────────────────────── + +import { apiClient } from '@/lib/api'; +import { + useFollowUser, + useActivityFeed, + useSocialInteractions, +} from '@/hooks/useSocialFeatures'; +import SocialProfile from '@/components/social/SocialProfile'; +import ActivityFeed from '@/components/social/ActivityFeed'; +import SocialInteractions from '@/components/social/SocialInteractions'; +import { + formatFollowerCount, + getRelativeTime, + groupActivitiesByDate, +} from '@/utils/socialUtils'; +import type { Activity } from '@/utils/socialUtils'; + +// ─── socialUtils ────────────────────────────────────────────────────────────── + +describe('formatFollowerCount', () => { + it('returns plain number for < 1000', () => expect(formatFollowerCount(999)).toBe('999')); + it('formats thousands', () => expect(formatFollowerCount(1200)).toBe('1.2K')); + it('formats exact thousands without decimal', () => expect(formatFollowerCount(2000)).toBe('2K')); + it('formats millions', () => expect(formatFollowerCount(1_500_000)).toBe('1.5M')); +}); + +describe('getRelativeTime', () => { + it('returns "just now" for < 60s', () => { + expect(getRelativeTime(new Date(Date.now() - 30_000))).toBe('just now'); + }); + it('returns minutes ago', () => { + expect(getRelativeTime(new Date(Date.now() - 2 * 60_000))).toBe('2 minutes ago'); + }); + it('returns hours ago', () => { + expect(getRelativeTime(new Date(Date.now() - 3 * 3600_000))).toBe('3 hours ago'); + }); + it('returns singular correctly', () => { + expect(getRelativeTime(new Date(Date.now() - 1 * 3600_000))).toBe('1 hour ago'); + }); +}); + +describe('groupActivitiesByDate', () => { + it('groups today activities under "Today"', () => { + const activity: Activity = { + id: '1', + actorId: 'u1', + actorName: 'Alice', + action: 'liked', + createdAt: new Date(), + }; + const groups = groupActivitiesByDate([activity]); + expect(groups['Today']).toHaveLength(1); + }); + + it('groups yesterday activities under "Yesterday"', () => { + const d = new Date(); + d.setDate(d.getDate() - 1); + const activity: Activity = { + id: '2', + actorId: 'u1', + actorName: 'Bob', + action: 'commented', + createdAt: d, + }; + const groups = groupActivitiesByDate([activity]); + expect(groups['Yesterday']).toHaveLength(1); + }); +}); + +// ─── useFollowUser ──────────────────────────────────────────────────────────── + +describe('useFollowUser', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ isFollowing: false }); + vi.mocked(apiClient.post).mockResolvedValue({}); + vi.mocked(apiClient.delete).mockResolvedValue({}); + }); + + it('initializes isFollowing from API', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ isFollowing: true }); + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.isFollowing).toBe(true)); + }); + + it('follow() sets isFollowing to true', async () => { + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.follow()); + expect(result.current.isFollowing).toBe(true); + expect(apiClient.post).toHaveBeenCalledWith('/api/social/follow/user-1', {}); + }); + + it('unfollow() sets isFollowing to false', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ isFollowing: true }); + const { result } = renderHook(() => useFollowUser('user-1')); + await waitFor(() => expect(result.current.isFollowing).toBe(true)); + await act(() => result.current.unfollow()); + expect(result.current.isFollowing).toBe(false); + expect(apiClient.delete).toHaveBeenCalledWith('/api/social/follow/user-1'); + }); +}); + +// ─── useActivityFeed ───────────────────────────────────────────────────────── + +describe('useActivityFeed', () => { + const mockActivities: Activity[] = [ + { id: '1', actorId: 'u1', actorName: 'Alice', action: 'liked a post', createdAt: new Date() }, + { id: '2', actorId: 'u2', actorName: 'Bob', action: 'commented', createdAt: new Date() }, + ]; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ data: mockActivities, nextCursor: undefined }); + }); + + it('loads activities on mount', async () => { + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.activities).toHaveLength(2); + }); + + it('hasMore is false when no nextCursor', async () => { + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(false); + }); + + it('hasMore is true when nextCursor present', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: mockActivities, + nextCursor: 'cursor-abc', + }); + const { result } = renderHook(() => useActivityFeed('user-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(true); + }); +}); + +// ─── useSocialInteractions ──────────────────────────────────────────────────── + +describe('useSocialInteractions', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: false, comments: [] }); + vi.mocked(apiClient.post).mockResolvedValue({ + id: 'c1', + authorId: 'u1', + authorName: 'Alice', + body: 'Nice!', + createdAt: new Date(), + }); + vi.mocked(apiClient.delete).mockResolvedValue({}); + }); + + it('loads initial likes and liked state', async () => { + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.likes).toBe(5)); + expect(result.current.liked).toBe(false); + }); + + it('toggleLike increments likes when not liked', async () => { + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.toggleLike()); + expect(result.current.likes).toBe(6); + expect(result.current.liked).toBe(true); + }); + + it('toggleLike decrements likes when already liked', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: true, comments: [] }); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.liked).toBe(true)); + await act(() => result.current.toggleLike()); + expect(result.current.likes).toBe(4); + expect(result.current.liked).toBe(false); + }); + + it('addComment appends to comments list', async () => { + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.addComment('Nice!')); + expect(result.current.comments).toHaveLength(1); + expect(result.current.comments[0].body).toBe('Nice!'); + }); +}); + +// ─── SocialProfile ──────────────────────────────────────────────────────────── + +describe('SocialProfile', () => { + const user = { + id: 'u1', + name: 'Alice Smith', + bio: 'Educator and developer', + followerCount: 1200, + followingCount: 340, + }; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ isFollowing: false }); + }); + + it('renders user name and bio', () => { + render(); + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Educator and developer')).toBeInTheDocument(); + }); + + it('renders formatted follower/following counts', () => { + render(); + expect(screen.getByText('1.2K')).toBeInTheDocument(); + expect(screen.getByText('340')).toBeInTheDocument(); + }); + + it('renders Follow button for non-own profile', () => { + render(); + expect(screen.getByRole('button', { name: /follow/i })).toBeInTheDocument(); + }); + + it('does not render Follow button for own profile', () => { + render(); + expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument(); + }); + + it('renders tab navigation', () => { + render(); + expect(screen.getByText('Posts')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); + expect(screen.getByText('Analytics')).toBeInTheDocument(); + }); + + it('switches tab content on click', async () => { + const user_ = userEvent.setup(); + render(); + await user_.click(screen.getByText('Activity')); + expect(screen.getByText('Recent activity will appear here.')).toBeInTheDocument(); + }); +}); + +// ─── ActivityFeed ───────────────────────────────────────────────────────────── + +describe('ActivityFeed', () => { + const mockActivities: Activity[] = [ + { + id: '1', + actorId: 'u1', + actorName: 'Alice', + action: 'liked', + targetTitle: 'Intro to React', + createdAt: new Date(), + }, + ]; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ data: mockActivities, nextCursor: undefined }); + // Mock IntersectionObserver + global.IntersectionObserver = vi.fn().mockImplementation((cb) => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })); + }); + + it('renders activity items after loading', async () => { + render(); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + expect(screen.getByText('liked')).toBeInTheDocument(); + expect(screen.getByText('Intro to React')).toBeInTheDocument(); + }); + + it('shows skeleton while loading', () => { + vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('shows empty state when no activities', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: [], nextCursor: undefined }); + render(); + await waitFor(() => expect(screen.getByText('No activity yet.')).toBeInTheDocument()); + }); +}); + +// ─── SocialInteractions ─────────────────────────────────────────────────────── + +describe('SocialInteractions', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 10, liked: false, comments: [] }); + vi.mocked(apiClient.post).mockResolvedValue({ + id: 'c1', + authorId: 'u1', + authorName: 'Alice', + body: 'Great post!', + createdAt: new Date(), + }); + vi.mocked(apiClient.delete).mockResolvedValue({}); + // jsdom doesn't implement clipboard; define it once with a spy + const clipboardMock = { writeText: vi.fn().mockResolvedValue(undefined) }; + Object.defineProperty(navigator, 'clipboard', { + value: clipboardMock, + writable: true, + configurable: true, + }); + }); + + it('renders like count', async () => { + render(); + await waitFor(() => expect(screen.getByText('10')).toBeInTheDocument()); + }); + + it('toggleLike updates count on click', async () => { + const user_ = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('10')); + await user_.click(screen.getByLabelText('Like')); + await waitFor(() => expect(screen.getByText('11')).toBeInTheDocument()); + }); + + it('shows comment input when comments button clicked', async () => { + const user_ = userEvent.setup(); + render(); + await user_.click(screen.getByLabelText('Toggle comments')); + expect(screen.getByPlaceholderText('Add a comment…')).toBeInTheDocument(); + }); + + it('copies link on share click and shows Copied! feedback', async () => { + // Stub clipboard at the global level so the component can call it + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + const user_ = userEvent.setup(); + render(); + await user_.click(screen.getByLabelText('Copy link')); + await waitFor(() => expect(screen.getByText('Copied!')).toBeInTheDocument()); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/hooks/useSocialFeatures.tsx b/src/hooks/useSocialFeatures.tsx new file mode 100644 index 00000000..5a8eeb8c --- /dev/null +++ b/src/hooks/useSocialFeatures.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { apiClient } from '@/lib/api'; +import type { Activity } from '@/utils/socialUtils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Comment { + id: string; + authorId: string; + authorName: string; + authorAvatar?: string; + body: string; + createdAt: Date; +} + +// ─── useFollowUser ──────────────────────────────────────────────────────────── + +export function useFollowUser(userId: string) { + const [isFollowing, setIsFollowing] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + apiClient + .get<{ isFollowing: boolean }>(`/api/social/follow/${userId}`) + .then((r) => setIsFollowing(r.isFollowing)) + .catch(() => {}); + }, [userId]); + + const follow = useCallback(async () => { + setLoading(true); + try { + await apiClient.post(`/api/social/follow/${userId}`, {}); + setIsFollowing(true); + } finally { + setLoading(false); + } + }, [userId]); + + const unfollow = useCallback(async () => { + setLoading(true); + try { + await apiClient.delete(`/api/social/follow/${userId}`); + setIsFollowing(false); + } finally { + setLoading(false); + } + }, [userId]); + + return { isFollowing, follow, unfollow, loading }; +} + +// ─── useActivityFeed ───────────────────────────────────────────────────────── + +export function useActivityFeed(userId: string) { + const [activities, setActivities] = useState([]); + const [cursor, setCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + + const load = useCallback( + async (nextCursor?: string) => { + setLoading(true); + try { + const params = new URLSearchParams({ limit: '20' }); + if (nextCursor) params.set('cursor', nextCursor); + const res = await apiClient.get<{ data: Activity[]; nextCursor?: string }>( + `/api/social/feed/${userId}?${params}`, + ); + setActivities((prev) => + nextCursor + ? [...prev, ...res.data.map((a) => ({ ...a, createdAt: new Date(a.createdAt) }))] + : res.data.map((a) => ({ ...a, createdAt: new Date(a.createdAt) })), + ); + setCursor(res.nextCursor); + setHasMore(!!res.nextCursor); + } catch { + setHasMore(false); + } finally { + setLoading(false); + } + }, + [userId], + ); + + useEffect(() => { + load(); + }, [load]); + + const loadMore = useCallback(() => { + if (!loading && hasMore) load(cursor); + }, [loading, hasMore, cursor, load]); + + return { activities, loadMore, loading, hasMore }; +} + +// ─── useSocialInteractions ──────────────────────────────────────────────────── + +export function useSocialInteractions(contentId: string) { + const [likes, setLikes] = useState(0); + const [liked, setLiked] = useState(false); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + apiClient + .get<{ likes: number; liked: boolean; comments: Comment[] }>( + `/api/social/interactions/${contentId}`, + ) + .then((r) => { + setLikes(r.likes); + setLiked(r.liked); + setComments(r.comments.map((c) => ({ ...c, createdAt: new Date(c.createdAt) }))); + }) + .catch(() => {}); + }, [contentId]); + + const toggleLike = useCallback(async () => { + setLoading(true); + try { + if (liked) { + await apiClient.delete(`/api/social/interactions/${contentId}/like`); + setLikes((n) => n - 1); + setLiked(false); + } else { + await apiClient.post(`/api/social/interactions/${contentId}/like`, {}); + setLikes((n) => n + 1); + setLiked(true); + } + } finally { + setLoading(false); + } + }, [contentId, liked]); + + const addComment = useCallback( + async (body: string) => { + setLoading(true); + try { + const comment = await apiClient.post( + `/api/social/interactions/${contentId}/comments`, + { body }, + ); + setComments((prev) => [...prev, { ...comment, createdAt: new Date(comment.createdAt) }]); + } finally { + setLoading(false); + } + }, + [contentId], + ); + + return { likes, liked, comments, toggleLike, addComment, loading }; +} diff --git a/src/utils/socialUtils.ts b/src/utils/socialUtils.ts new file mode 100644 index 00000000..6b076ad3 --- /dev/null +++ b/src/utils/socialUtils.ts @@ -0,0 +1,54 @@ +export interface Activity { + id: string; + actorId: string; + actorName: string; + actorAvatar?: string; + action: string; + targetId?: string; + targetTitle?: string; + createdAt: Date; +} + +/** Format large numbers: 1200 → "1.2K", 1_500_000 → "1.5M" */ +export function formatFollowerCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + return String(n); +} + +/** Returns a human-readable relative time string: "2 hours ago", "just now", etc. */ +export function getRelativeTime(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 's'} ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`; + const years = Math.floor(days / 365); + return `${years} year${years === 1 ? '' : 's'} ago`; +} + +/** Groups activities by calendar date label ("Today", "Yesterday", or "MMM D, YYYY"). */ +export function groupActivitiesByDate(activities: Activity[]): Record { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + const label = (d: Date): string => { + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }; + + return activities.reduce>((acc, activity) => { + const key = label(new Date(activity.createdAt)); + (acc[key] ??= []).push(activity); + return acc; + }, {}); +} diff --git a/vitest.config.ts b/vitest.config.ts index ce8fa876..27fe090f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ globalSetup: [], setupFiles: ['./src/testing/test-setup.ts'], globals: true, + exclude: ['**/node_modules/**', '**/.next/**'], }, resolve: { alias: { From 42bf0a5d489c7ab21c44053c6854f37e895d9a9f Mon Sep 17 00:00:00 2001 From: JoesWalker Date: Tue, 28 Apr 2026 12:54:52 +0000 Subject: [PATCH 2/2] feat(ai): implement AI-powered learning assistant components --- src/components/ai/IntelligentProgress.tsx | 100 ++++++ src/components/ai/LearningAssistant.tsx | 155 +++++++++ src/components/ai/NaturalLanguageQuery.tsx | 101 ++++++ .../ai/PersonalizedRecommendations.tsx | 79 +++++ src/components/ai/SmartNotifications.tsx | 85 +++++ .../ai/__tests__/aiComponents.test.tsx | 300 ++++++++++++++++++ 6 files changed, 820 insertions(+) create mode 100644 src/components/ai/IntelligentProgress.tsx create mode 100644 src/components/ai/LearningAssistant.tsx create mode 100644 src/components/ai/NaturalLanguageQuery.tsx create mode 100644 src/components/ai/PersonalizedRecommendations.tsx create mode 100644 src/components/ai/SmartNotifications.tsx create mode 100644 src/components/ai/__tests__/aiComponents.test.tsx diff --git a/src/components/ai/IntelligentProgress.tsx b/src/components/ai/IntelligentProgress.tsx new file mode 100644 index 00000000..5466c826 --- /dev/null +++ b/src/components/ai/IntelligentProgress.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { TrendingUp } from 'lucide-react'; +import { apiClient } from '@/lib/api'; + +// GET /api/ai/progress → { courses: CourseProgress[]; insights: string[] } + +interface CourseProgress { + id: string; + title: string; + percent: number; +} + +interface ProgressData { + courses: CourseProgress[]; + insights: string[]; +} + +function ProgressBar({ percent }: { percent: number }) { + const clamped = Math.min(100, Math.max(0, percent)); + return ( +
+
+
+ ); +} + +export default function IntelligentProgress() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + apiClient + .get('/api/ai/progress') + .then(setData) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+ +

Your Progress

+
+ +
+ {loading && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+ ))} +
+ )} + + {error && ( +

Failed to load progress.

+ )} + + {data && ( + <> +
+ {data.courses.map((course) => ( +
+
+ {course.title} + {course.percent}% +
+ +
+ ))} +
+ + {data.insights.length > 0 && ( +
+ {data.insights.map((insight, i) => ( +

+ 💡 {insight} +

+ ))} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/ai/LearningAssistant.tsx b/src/components/ai/LearningAssistant.tsx new file mode 100644 index 00000000..e4158633 --- /dev/null +++ b/src/components/ai/LearningAssistant.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Send, Bot, User } from 'lucide-react'; +import { apiClient } from '@/lib/api'; + +// POST /api/ai/chat — { message: string; context?: string } → { reply: string } + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +interface LearningAssistantProps { + context?: string; +} + +export default function LearningAssistant({ context }: LearningAssistantProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, loading]); + + const send = useCallback(async () => { + const text = input.trim(); + if (!text || loading) return; + + const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: text }; + setMessages((prev) => [...prev, userMsg]); + setInput(''); + setLoading(true); + + try { + const { reply } = await apiClient.post<{ reply: string }>('/api/ai/chat', { + message: text, + context, + }); + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: reply }, + ]); + } catch { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: 'Sorry, something went wrong.' }, + ]); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }, [input, loading, context]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( +
+ {/* Header */} +
+ +

Learning Assistant

+
+ + {/* Message thread */} +
+ {messages.length === 0 && ( +

+ Ask me anything about your courses! +

+ )} + + {messages.map((msg) => ( +
+ {msg.role === 'assistant' && ( + + )} +
+ {msg.content} +
+ {msg.role === 'user' && ( + + )} +
+ ))} + + {/* Typing indicator */} + {loading && ( +
+ +
+ + {[0, 1, 2].map((i) => ( + + ))} + +
+
+ )} + +
+
+ + {/* Input */} +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask a question…" + aria-label="Message input" + disabled={loading} + className="flex-1 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white disabled:opacity-50" + /> + +
+
+ ); +} diff --git a/src/components/ai/NaturalLanguageQuery.tsx b/src/components/ai/NaturalLanguageQuery.tsx new file mode 100644 index 00000000..8148420f --- /dev/null +++ b/src/components/ai/NaturalLanguageQuery.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Search, ExternalLink } from 'lucide-react'; +import { apiClient } from '@/lib/api'; + +// POST /api/ai/search — { query: string } → { results: SearchResult[] } + +interface SearchResult { + id: string; + title: string; + description: string; + url: string; +} + +export default function NaturalLanguageQuery() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const search = useCallback(async () => { + const q = query.trim(); + if (!q || loading) return; + setLoading(true); + setError(false); + try { + const { results: res } = await apiClient.post<{ results: SearchResult[] }>( + '/api/ai/search', + { query: q }, + ); + setResults(res); + } catch { + setError(true); + setResults(null); + } finally { + setLoading(false); + } + }, [query, loading]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') search(); + }; + + return ( +
+
+
+
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask anything, e.g. 'intro to machine learning'…" + aria-label="Natural language search" + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white" + /> +
+ +
+
+ +
+ {error && ( +

Search failed. Please try again.

+ )} + + {results !== null && results.length === 0 && ( +

No results found.

+ )} + + {results?.map((item) => ( +
+

{item.title}

+

+ {item.description} +

+ + Open + +
+ ))} +
+
+ ); +} diff --git a/src/components/ai/PersonalizedRecommendations.tsx b/src/components/ai/PersonalizedRecommendations.tsx new file mode 100644 index 00000000..e6733b60 --- /dev/null +++ b/src/components/ai/PersonalizedRecommendations.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ExternalLink, Sparkles } from 'lucide-react'; +import { apiClient } from '@/lib/api'; + +// GET /api/ai/recommendations → { items: Recommendation[] } + +interface Recommendation { + id: string; + title: string; + reason: string; + url: string; +} + +function SkeletonCard() { + return ( +
+
+
+
+
+ ); +} + +export default function PersonalizedRecommendations() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + apiClient + .get<{ items: Recommendation[] }>('/api/ai/recommendations') + .then((r) => setItems(r.items)) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+ +

+ Recommended for You +

+
+ +
+ {loading && Array.from({ length: 3 }).map((_, i) => )} + + {error && ( +

+ Failed to load recommendations. +

+ )} + + {!loading && !error && items.length === 0 && ( +

No recommendations yet.

+ )} + + {items.map((item) => ( +
+

{item.title}

+

{item.reason}

+ + View course + +
+ ))} +
+
+ ); +} diff --git a/src/components/ai/SmartNotifications.tsx b/src/components/ai/SmartNotifications.tsx new file mode 100644 index 00000000..34c8f164 --- /dev/null +++ b/src/components/ai/SmartNotifications.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Bell, X } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import { useNotification } from '@/hooks/use-notification'; + +// GET /api/ai/reminders → { reminders: Reminder[] } +// DELETE /api/ai/reminders/:id + +interface Reminder { + id: string; + title: string; + scheduledAt: string; // ISO string +} + +export default function SmartNotifications() { + const [reminders, setReminders] = useState([]); + const [loading, setLoading] = useState(true); + const { success, error } = useNotification(); + + useEffect(() => { + apiClient + .get<{ reminders: Reminder[] }>('/api/ai/reminders') + .then((r) => setReminders(r.reminders)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const dismiss = useCallback( + async (id: string) => { + try { + await apiClient.delete(`/api/ai/reminders/${id}`); + setReminders((prev) => prev.filter((r) => r.id !== id)); + success('Reminder dismissed'); + } catch { + error('Failed to dismiss reminder'); + } + }, + [success, error], + ); + + return ( +
+
+ +

Study Reminders

+
+ +
+ {loading && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))} +
+ )} + + {!loading && reminders.length === 0 && ( +

No upcoming reminders.

+ )} + + {reminders.map((reminder) => ( +
+
+

+ {reminder.title} +

+

+ {new Date(reminder.scheduledAt).toLocaleString()} +

+
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/ai/__tests__/aiComponents.test.tsx b/src/components/ai/__tests__/aiComponents.test.tsx new file mode 100644 index 00000000..c4d8f5d3 --- /dev/null +++ b/src/components/ai/__tests__/aiComponents.test.tsx @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('@/hooks/use-notification', () => ({ + useNotification: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})); + +// ─── Imports after mocks ─────────────────────────────────────────────────────── + +import { apiClient } from '@/lib/api'; +import LearningAssistant from '@/components/ai/LearningAssistant'; +import PersonalizedRecommendations from '@/components/ai/PersonalizedRecommendations'; +import IntelligentProgress from '@/components/ai/IntelligentProgress'; +import SmartNotifications from '@/components/ai/SmartNotifications'; +import NaturalLanguageQuery from '@/components/ai/NaturalLanguageQuery'; + +// ─── LearningAssistant ──────────────────────────────────────────────────────── + +describe('LearningAssistant', () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockResolvedValue({ reply: 'Hello from AI!' }); + }); + + it('renders input and send button', () => { + render(); + expect(screen.getByLabelText('Message input')).toBeInTheDocument(); + expect(screen.getByLabelText('Send message')).toBeInTheDocument(); + }); + + it('sends message and displays assistant response', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Message input'), 'What is React?'); + await user.click(screen.getByLabelText('Send message')); + + await waitFor(() => expect(screen.getByText('Hello from AI!')).toBeInTheDocument()); + expect(apiClient.post).toHaveBeenCalledWith('/api/ai/chat', { + message: 'What is React?', + context: undefined, + }); + }); + + it('shows typing indicator while loading', async () => { + vi.mocked(apiClient.post).mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Message input'), 'Hello'); + await user.click(screen.getByLabelText('Send message')); + + expect(screen.getByLabelText('Assistant is typing')).toBeInTheDocument(); + }); + + it('shows error message when API fails', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error')); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Message input'), 'Hello'); + await user.click(screen.getByLabelText('Send message')); + + await waitFor(() => + expect(screen.getByText('Sorry, something went wrong.')).toBeInTheDocument(), + ); + }); + + it('sends message on Enter key', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Message input'), 'Hello{Enter}'); + + await waitFor(() => expect(apiClient.post).toHaveBeenCalled()); + }); + + it('clears input after sending', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('Message input'); + await user.type(input, 'Hello'); + await user.click(screen.getByLabelText('Send message')); + + await waitFor(() => expect((input as HTMLInputElement).value).toBe('')); + }); +}); + +// ─── PersonalizedRecommendations ───────────────────────────────────────────── + +describe('PersonalizedRecommendations', () => { + const mockItems = [ + { id: '1', title: 'Intro to TypeScript', reason: 'Based on your React progress', url: '/courses/ts' }, + { id: '2', title: 'Advanced CSS', reason: 'Matches your interests', url: '/courses/css' }, + ]; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ items: mockItems }); + }); + + it('shows skeleton while loading', () => { + vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); + render(); + expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('renders fetched recommendations', async () => { + render(); + await waitFor(() => expect(screen.getByText('Intro to TypeScript')).toBeInTheDocument()); + expect(screen.getByText('Based on your React progress')).toBeInTheDocument(); + expect(screen.getByText('Advanced CSS')).toBeInTheDocument(); + }); + + it('renders CTA links for each item', async () => { + render(); + await waitFor(() => screen.getByText('Intro to TypeScript')); + const links = screen.getAllByText('View course'); + expect(links).toHaveLength(2); + }); + + it('shows error state on failure', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => + expect(screen.getByText('Failed to load recommendations.')).toBeInTheDocument(), + ); + }); + + it('shows empty state when no items', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ items: [] }); + render(); + await waitFor(() => + expect(screen.getByText('No recommendations yet.')).toBeInTheDocument(), + ); + }); +}); + +// ─── IntelligentProgress ───────────────────────────────────────────────────── + +describe('IntelligentProgress', () => { + const mockData = { + courses: [ + { id: '1', title: 'React Fundamentals', percent: 80 }, + { id: '2', title: 'Node.js Basics', percent: 45 }, + ], + insights: ["You're 80% through React Fundamentals", 'Strong in hooks, review context'], + }; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue(mockData); + }); + + it('shows skeleton while loading', () => { + vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); + render(); + expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('renders course progress bars', async () => { + render(); + await waitFor(() => expect(screen.getByText('React Fundamentals')).toBeInTheDocument()); + expect(screen.getByText('80%')).toBeInTheDocument(); + expect(screen.getByText('Node.js Basics')).toBeInTheDocument(); + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + + it('renders insights', async () => { + render(); + await waitFor(() => + expect(screen.getByText(/80% through React Fundamentals/)).toBeInTheDocument(), + ); + expect(screen.getByText(/Strong in hooks/)).toBeInTheDocument(); + }); + + it('shows error state on failure', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => + expect(screen.getByText('Failed to load progress.')).toBeInTheDocument(), + ); + }); +}); + +// ─── SmartNotifications ─────────────────────────────────────────────────────── + +describe('SmartNotifications', () => { + const mockReminders = [ + { id: 'r1', title: 'Review React hooks', scheduledAt: '2026-05-01T10:00:00Z' }, + { id: 'r2', title: 'Complete CSS quiz', scheduledAt: '2026-05-02T14:00:00Z' }, + ]; + + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ reminders: mockReminders }); + vi.mocked(apiClient.delete).mockResolvedValue({}); + }); + + it('renders reminders after loading', async () => { + render(); + await waitFor(() => expect(screen.getByText('Review React hooks')).toBeInTheDocument()); + expect(screen.getByText('Complete CSS quiz')).toBeInTheDocument(); + }); + + it('dismisses a reminder on button click', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('Review React hooks')); + + await user.click(screen.getByLabelText('Dismiss Review React hooks')); + + await waitFor(() => + expect(screen.queryByText('Review React hooks')).not.toBeInTheDocument(), + ); + expect(apiClient.delete).toHaveBeenCalledWith('/api/ai/reminders/r1'); + }); + + it('shows empty state when no reminders', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ reminders: [] }); + render(); + await waitFor(() => + expect(screen.getByText('No upcoming reminders.')).toBeInTheDocument(), + ); + }); +}); + +// ─── NaturalLanguageQuery ───────────────────────────────────────────────────── + +describe('NaturalLanguageQuery', () => { + const mockResults = [ + { id: '1', title: 'Intro to ML', description: 'Machine learning basics', url: '/courses/ml' }, + { id: '2', title: 'Deep Learning', description: 'Neural networks', url: '/courses/dl' }, + ]; + + beforeEach(() => { + vi.mocked(apiClient.post).mockResolvedValue({ results: mockResults }); + }); + + it('renders search input', () => { + render(); + expect(screen.getByLabelText('Natural language search')).toBeInTheDocument(); + }); + + it('submits query and renders results', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Natural language search'), 'machine learning'); + await user.click(screen.getByLabelText('Search')); + + await waitFor(() => expect(screen.getByText('Intro to ML')).toBeInTheDocument()); + expect(screen.getByText('Deep Learning')).toBeInTheDocument(); + expect(apiClient.post).toHaveBeenCalledWith('/api/ai/search', { query: 'machine learning' }); + }); + + it('shows empty state when no results', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ results: [] }); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Natural language search'), 'xyzzy'); + await user.click(screen.getByLabelText('Search')); + + await waitFor(() => expect(screen.getByText('No results found.')).toBeInTheDocument()); + }); + + it('shows error state on failure', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('fail')); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Natural language search'), 'test'); + await user.click(screen.getByLabelText('Search')); + + await waitFor(() => + expect(screen.getByText('Search failed. Please try again.')).toBeInTheDocument(), + ); + }); + + it('submits on Enter key', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Natural language search'), 'react{Enter}'); + + await waitFor(() => expect(apiClient.post).toHaveBeenCalled()); + }); +});