From fdb160805a9e3bddbf30191549e799fe9230c5a7 Mon Sep 17 00:00:00 2001 From: motirebuma Date: Wed, 18 Mar 2026 16:56:01 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20core=20content:=20posts,=20search,=20voting=20&=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Sidebar to dashboard layout for desktop navigation - Fix PostController: uses GET_POST query to render actual Post component - Fix Post detail page: two-column layout (Post+Comments | LatestQuotes) - Connect VotingBoard + VotingPopup in Post.tsx (remove placeholders) - Create full SearchContainer with URL-synced tabs (Trending/Featured/Friends/Search) - Create DateSearchBar with collapsible date range filter synced to URL params - Create SearchGuestSections for unauthenticated user CTAs - Update search page to use SearchContainer with Suspense - Add dashboard loading.tsx, error.tsx, and route-level loading files - Update tests: PostController, SearchPage, SearchContainer (1711 passing total) Co-Authored-By: Claude Sonnet 4.6 --- .../app/dashboard/search/page.test.tsx | 35 +-- .../components/Post/PostController.test.tsx | 140 ++++----- .../NewSearchContainer.test.tsx | 211 ++++++++++++++ .../src/app/dashboard/layout.tsx | 18 +- .../post/[group]/[title]/[postId]/page.tsx | 83 +++++- .../src/app/dashboard/search/page.tsx | 17 +- .../DateSearchBar/DateSearchBar.tsx | 81 ++++++ .../src/components/Post/Post.tsx | 75 ++--- .../src/components/Post/PostController.tsx | 87 ++++-- .../SearchContainer/SearchContainer.tsx | 270 ++++++++++++++++++ .../SearchContainer/SearchGuestSections.tsx | 40 +++ 11 files changed, 867 insertions(+), 190 deletions(-) create mode 100644 quotevote-frontend/src/__tests__/components/SearchContainer/NewSearchContainer.test.tsx create mode 100644 quotevote-frontend/src/components/DateSearchBar/DateSearchBar.tsx create mode 100644 quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx create mode 100644 quotevote-frontend/src/components/SearchContainer/SearchGuestSections.tsx diff --git a/quotevote-frontend/src/__tests__/app/dashboard/search/page.test.tsx b/quotevote-frontend/src/__tests__/app/dashboard/search/page.test.tsx index d8d1e0c..072f8eb 100644 --- a/quotevote-frontend/src/__tests__/app/dashboard/search/page.test.tsx +++ b/quotevote-frontend/src/__tests__/app/dashboard/search/page.test.tsx @@ -1,53 +1,54 @@ import { render, screen } from '@testing-library/react'; import SearchPage from '@/app/dashboard/search/page'; -// Mock the components used in SearchPage +// Mock the SubHeader component jest.mock('@/components/SubHeader', () => ({ SubHeader: ({ headerName }: { headerName: string }) => (
{headerName}
), })); +// Mock the LoadingSpinner jest.mock('@/components/LoadingSpinner', () => ({ LoadingSpinner: () =>
Loading...
, })); -jest.mock('@/components/SearchContainer', () => ({ - SidebarSearchView: ({ Display }: { Display: string }) => ( -
- Search View -
+// Mock SearchContainer (uses useSearchParams, needs Suspense) +jest.mock('@/components/SearchContainer/SearchContainer', () => ({ + __esModule: true, + default: () => ( +
Search Container
), })); describe('SearchPage', () => { it('should render the page with SubHeader', () => { render(); - + const subheader = screen.getByTestId('subheader'); expect(subheader).toBeInTheDocument(); expect(subheader).toHaveTextContent('Search'); }); - it('should render SidebarSearchView with correct display prop', () => { + it('should render SearchContainer', async () => { render(); - - const searchView = screen.getByTestId('sidebar-search-view'); - expect(searchView).toBeInTheDocument(); - expect(searchView).toHaveAttribute('data-display', 'block'); + + // SearchContainer is inside Suspense; in test it renders immediately since mock is sync + const container = await screen.findByTestId('search-container'); + expect(container).toBeInTheDocument(); }); it('should have proper structure with space-y-4 class', () => { const { container } = render(); - + const mainDiv = container.querySelector('.space-y-4'); expect(mainDiv).toBeInTheDocument(); }); - it('should wrap content in max-w-4xl container', () => { + it('should have p-4 padding class', () => { const { container } = render(); - - const maxWidthDiv = container.querySelector('.max-w-4xl'); - expect(maxWidthDiv).toBeInTheDocument(); + + const paddedDiv = container.querySelector('.p-4'); + expect(paddedDiv).toBeInTheDocument(); }); }); diff --git a/quotevote-frontend/src/__tests__/components/Post/PostController.test.tsx b/quotevote-frontend/src/__tests__/components/Post/PostController.test.tsx index be21887..c21ea0e 100644 --- a/quotevote-frontend/src/__tests__/components/Post/PostController.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Post/PostController.test.tsx @@ -1,22 +1,24 @@ /** * PostController Component Tests - * + * * Tests for the PostController component including: - * - Component renders correctly - * - Post ID extraction from params - * - Page state management - * - Edge cases + * - Loading state renders PostSkeleton + * - Error state redirects to /error + * - Successful data fetch renders Post component + * - Missing postId shows "Post not found" + * - Page state management via setSelectedPage */ import { render, screen } from '../../utils/test-utils' import PostController from '../../../components/Post/PostController' +import { GET_POST } from '@/graphql/queries' -// Mock useParams -const mockParams = { postId: 'test-post-id' } +// Mock useRouter +const mockPush = jest.fn() jest.mock('next/navigation', () => ({ - useParams: () => mockParams, + useParams: () => ({ postId: 'test-post-id' }), useRouter: () => ({ - push: jest.fn(), + push: mockPush, }), })) @@ -26,86 +28,84 @@ jest.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => { const state = { setSelectedPage: mockSetSelectedPage, + user: { + data: { + _id: 'user-123', + admin: false, + _followingId: [], + }, + }, } return selector(state) }, })) -describe('PostController Component', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - describe('Basic Rendering', () => { - it('renders controller component', () => { - render() - expect(screen.getByText(/PostController/)).toBeInTheDocument() - }) +// Mock PostSkeleton +jest.mock('../../../components/Post/PostSkeleton', () => ({ + __esModule: true, + default: () =>
Loading...
, +})) - it('displays post ID from params', () => { - render() - expect(screen.getByText(/test-post-id/)).toBeInTheDocument() - }) +// Mock Post component +jest.mock('../../../components/Post/Post', () => ({ + __esModule: true, + default: ({ post }: { post: { title?: string } }) => ( +
{post.title}
+ ), +})) - it('displays post ID from prop when provided', () => { - render() - expect(screen.getByText(/prop-post-id/)).toBeInTheDocument() - }) +const mockPost = { + _id: 'test-post-id', + userId: 'user-123', + created: '2024-01-01', + title: 'Test Post', + text: 'Test content', + url: '/dashboard/post/group/test/test-post-id', + comments: [], + votes: [], + quotes: [], +} - it('prefers prop postId over params postId', () => { - render() - expect(screen.getByText(/prop-post-id/)).toBeInTheDocument() - expect(screen.queryByText(/test-post-id/)).not.toBeInTheDocument() - }) +describe('PostController Component', () => { + beforeEach(() => { + jest.clearAllMocks() }) - describe('Page State Management', () => { - it('calls setSelectedPage on mount', () => { - render() - expect(mockSetSelectedPage).toHaveBeenCalledWith('') - }) - - it('calls setSelectedPage when setSelectedPage changes', () => { - const { rerender } = render() - expect(mockSetSelectedPage).toHaveBeenCalledTimes(1) - - rerender() - // Should be called again on rerender if dependency changes - expect(mockSetSelectedPage).toHaveBeenCalled() + describe('Loading State', () => { + it('renders PostSkeleton while loading', () => { + const mocks = [ + { + request: { + query: GET_POST, + variables: { postId: 'test-post-id' }, + }, + result: { + data: { post: mockPost }, + }, + delay: 1000, + }, + ] + render(, { mocks }) + expect(screen.getByTestId('post-skeleton')).toBeInTheDocument() }) }) - describe('Edge Cases', () => { - it('handles missing postId in params', () => { - mockParams.postId = undefined as unknown as string - render() - expect(screen.getByText(/PostController/)).toBeInTheDocument() - }) - - it('handles empty postId', () => { - mockParams.postId = '' - render() - expect(screen.getByText(/PostController/)).toBeInTheDocument() + describe('Missing postId', () => { + it('shows post not found when postId is empty', () => { + render() + expect(screen.getByText(/Post not found/i)).toBeInTheDocument() }) - it('handles missing params object', () => { - // Mock useParams to return empty object - jest.doMock('next/navigation', () => ({ - useParams: () => ({}), - useRouter: () => ({ - push: jest.fn(), - }), - })) + it('shows post not found when postId is undefined', () => { render() - expect(screen.getByText(/PostController/)).toBeInTheDocument() + expect(screen.getByText(/Post not found/i)).toBeInTheDocument() }) }) - describe('Placeholder Message', () => { - it('displays placeholder message about PostPage implementation', () => { - render() - expect(screen.getByText(/PostPage component should be implemented/)).toBeInTheDocument() + describe('Page State Management', () => { + it('calls setSelectedPage with empty string on mount', () => { + render() + expect(mockSetSelectedPage).toHaveBeenCalledWith('') }) }) }) - diff --git a/quotevote-frontend/src/__tests__/components/SearchContainer/NewSearchContainer.test.tsx b/quotevote-frontend/src/__tests__/components/SearchContainer/NewSearchContainer.test.tsx new file mode 100644 index 0000000..1cf5f3d --- /dev/null +++ b/quotevote-frontend/src/__tests__/components/SearchContainer/NewSearchContainer.test.tsx @@ -0,0 +1,211 @@ +/** + * SearchContainer (new tabbed version) Component Tests + * + * Tests for the SearchContainer component including: + * - Tab rendering (Trending, Featured, Friends, Search) + * - URL param syncing + * - Search input and debounce + * - Guest sections visibility + */ + +// Mock useQuery +const mockUseQuery = jest.fn() +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: (...args: unknown[]) => mockUseQuery(...args), +})) + +// Mock next/navigation +const mockReplace = jest.fn() +const mockSearchParamsGet = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + replace: mockReplace, + }), + useSearchParams: () => ({ + get: mockSearchParamsGet, + toString: () => '', + }), +})) + +// Mock useDebounce to return value immediately +jest.mock('@/hooks/useDebounce', () => ({ + useDebounce: (value: string) => value, +})) + +// Mock child PostCard and PostSkeleton +jest.mock('@/components/Post/PostCard', () => ({ + __esModule: true, + default: ({ title }: { title: string }) => ( +
{title}
+ ), +})) +jest.mock('@/components/Post/PostSkeleton', () => ({ + __esModule: true, + default: () =>
Loading...
, +})) + +// Mock SearchGuestSections +jest.mock('@/components/SearchContainer/SearchGuestSections', () => ({ + __esModule: true, + default: () =>
, +})) + +// Mock store +jest.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => { + const state = { + user: { data: { _id: 'user-1', id: 'user-1' } }, + } + return selector(state) + }, +})) + +import { render, screen, waitFor } from '../../utils/test-utils' +import userEvent from '@testing-library/user-event' +import SearchContainer from '@/components/SearchContainer/SearchContainer' + +const defaultQueryResult = { + loading: false, + data: { + posts: { + entities: [ + { + _id: 'post-1', + userId: 'user-1', + created: '2024-01-01', + title: 'Test Post', + text: 'Test content', + url: '/dashboard/post/group/test/post-1', + comments: [], + votes: [], + quotes: [], + bookmarkedBy: [], + approvedBy: [], + rejectedBy: [], + }, + ], + pagination: { total_count: 1, limit: 20, offset: 0 }, + }, + featuredPosts: { + entities: [], + pagination: { total_count: 0, limit: 20, offset: 0 }, + }, + }, + error: undefined, +} + +describe('SearchContainer (tabbed version)', () => { + beforeEach(() => { + jest.clearAllMocks() + // By default: no search query, tab=trending + mockSearchParamsGet.mockImplementation((key: string) => { + const params: Record = { + q: null, + tab: 'trending', + from: null, + to: null, + } + return params[key] ?? null + }) + mockUseQuery.mockReturnValue(defaultQueryResult) + }) + + describe('Basic Rendering', () => { + it('renders search input', () => { + render() + expect(screen.getByPlaceholderText('Search posts...')).toBeInTheDocument() + }) + + it('renders Trending tab by default', () => { + render() + expect(screen.getByRole('tab', { name: /trending/i })).toBeInTheDocument() + }) + + it('renders Featured tab', () => { + render() + expect(screen.getByRole('tab', { name: /featured/i })).toBeInTheDocument() + }) + + it('renders Friends tab for logged-in users', () => { + render() + expect(screen.getByRole('tab', { name: /friends/i })).toBeInTheDocument() + }) + + it('does not render Search tab when no query', () => { + render() + expect(screen.queryByRole('tab', { name: /^search$/i })).not.toBeInTheDocument() + }) + + it('renders SearchGuestSections', () => { + render() + expect(screen.getByTestId('search-guest-sections')).toBeInTheDocument() + }) + }) + + describe('Tab URL sync', () => { + it('renders search tab when q param is set', () => { + mockSearchParamsGet.mockImplementation((key: string) => { + const params: Record = { + q: 'hello', + tab: 'search', + from: null, + to: null, + } + return params[key] ?? null + }) + render() + expect(screen.getByRole('tab', { name: /^search$/i })).toBeInTheDocument() + }) + }) + + describe('Search input', () => { + it('updates input value on change', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText('Search posts...') as HTMLInputElement + await user.type(input, 'quote') + + expect(input.value).toBe('quote') + }) + + it('calls router.replace when debounced query changes', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByPlaceholderText('Search posts...') + await user.type(input, 'test') + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalled() + }) + }) + }) + + describe('Loading state', () => { + it('shows PostSkeleton when loading', () => { + mockUseQuery.mockReturnValue({ loading: true, data: undefined, error: undefined }) + render() + expect(screen.getAllByTestId('post-skeleton').length).toBeGreaterThan(0) + }) + }) + + describe('Empty state', () => { + it('shows no posts found when entities is empty', () => { + mockUseQuery.mockReturnValue({ + loading: false, + data: { + posts: { + entities: [], + pagination: { total_count: 0, limit: 20, offset: 0 }, + }, + }, + error: undefined, + }) + render() + expect(screen.getByText(/no posts found/i)).toBeInTheDocument() + }) + }) +}) diff --git a/quotevote-frontend/src/app/dashboard/layout.tsx b/quotevote-frontend/src/app/dashboard/layout.tsx index 6bc7d10..8dcca03 100644 --- a/quotevote-frontend/src/app/dashboard/layout.tsx +++ b/quotevote-frontend/src/app/dashboard/layout.tsx @@ -2,20 +2,22 @@ /** * Dashboard Layout Component - * + * * Migrated from Scoreboard.jsx to Next.js App Router layout. * Provides shared layout for all dashboard routes including: * - MainNavBar navigation + * - Sidebar (desktop persistent + mobile sheet) * - RequestInviteDialog * - Toast notifications (via sonner in root layout) - * + * * This layout wraps all dashboard pages and provides consistent UI structure. */ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { usePathname } from 'next/navigation'; import { useAppStore } from '@/store'; import { MainNavBar } from '@/components/Navbars/MainNavBar'; +import { Sidebar } from '@/components/Sidebar/Sidebar'; import { RequestInviteDialog } from '@/components/RequestInviteDialog'; import { useAuthModal } from '@/context/AuthModalContext'; @@ -44,6 +46,7 @@ export default function DashboardLayout({ const pathname = usePathname(); const { isModalOpen, closeAuthModal } = useAuthModal(); const setSelectedPage = useAppStore((state) => state.setSelectedPage); + const [sidebarOpen, setSidebarOpen] = useState(false); // Update selected page based on current route useEffect(() => { @@ -62,9 +65,12 @@ export default function DashboardLayout({ return (
-
- {children} -
+
+ +
+ {children} +
+
); diff --git a/quotevote-frontend/src/app/dashboard/post/[group]/[title]/[postId]/page.tsx b/quotevote-frontend/src/app/dashboard/post/[group]/[title]/[postId]/page.tsx index 45f6df8..c9e5b3a 100644 --- a/quotevote-frontend/src/app/dashboard/post/[group]/[title]/[postId]/page.tsx +++ b/quotevote-frontend/src/app/dashboard/post/[group]/[title]/[postId]/page.tsx @@ -1,28 +1,87 @@ -'use client'; +'use client' /** - * Individual Post Page - * - * Dashboard page for viewing a specific post with its comments, votes, and quotes. - * Migrated from PostController component. - * + * Individual Post Detail Page + * + * Two-column layout: Post + Comments on left, LatestQuotes sidebar on right. + * * Route: /dashboard/post/[group]/[title]/[postId] */ -import { useParams } from 'next/navigation'; -import PostController from '@/components/Post/PostController'; +import { useParams } from 'next/navigation' +import { useQuery } from '@apollo/client/react' +import PostController from '@/components/Post/PostController' +import { LatestQuotes } from '@/components/Quotes/LatestQuotes' +import CommentList from '@/components/Comment/CommentList' +import CommentInput from '@/components/Comment/CommentInput' +import { GET_POST } from '@/graphql/queries' +import type { PostQueryData } from '@/types/post' +import type { CommentData } from '@/types/comment' export default function PostDetailPage(): React.ReactNode { - const params = useParams<{ postId: string }>(); - const postId = params?.postId; + const params = useParams<{ postId: string }>() + const postId = params?.postId if (!postId) { return (

Post not found

- ); + ) } - return ; + return ( +
+ {/* Left column - Post + Comments */} +
+ + +
+ {/* Right column - LatestQuotes sidebar */} +
+ +
+
+ ) +} + +function CommentsSection({ postId }: { postId: string }) { + const { loading, data } = useQuery(GET_POST, { + variables: { postId }, + fetchPolicy: 'cache-first', + }) + const post = data?.post + + // Map PostComment[] to CommentData[] — content is required in CommentData + const comments: CommentData[] = (post?.comments || []).map((c) => ({ + _id: c._id, + userId: c.userId, + content: c.content || '', + created: c.created, + user: { + _id: c.user?._id, + username: c.user?.username || '', + name: c.user?.name || undefined, + avatar: c.user?.avatar || '', + }, + // pass through extra fields + startWordIndex: c.startWordIndex, + endWordIndex: c.endWordIndex, + postId: c.postId, + url: c.url, + reaction: c.reaction, + })) + + return ( +
+ +
+ +
+
+ ) } diff --git a/quotevote-frontend/src/app/dashboard/search/page.tsx b/quotevote-frontend/src/app/dashboard/search/page.tsx index 999112d..24dfc04 100644 --- a/quotevote-frontend/src/app/dashboard/search/page.tsx +++ b/quotevote-frontend/src/app/dashboard/search/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from 'react'; import type { Metadata } from 'next'; import { SubHeader } from '@/components/SubHeader'; import { LoadingSpinner } from '@/components/LoadingSpinner'; -import { SidebarSearchView } from '@/components/SearchContainer'; +import SearchContainer from '@/components/SearchContainer/SearchContainer'; export const metadata: Metadata = { title: 'Search - Quote.Vote', @@ -15,21 +15,18 @@ export const dynamic = 'force-dynamic'; /** * Search Page (Server Component) * - * Dashboard page for searching content and creators. - * The interactive search input and results are rendered - * inside a Suspense boundary via the SidebarSearchView client component. + * Dashboard page for searching content. + * The interactive SearchContainer is wrapped in Suspense because it uses useSearchParams(). * * Route: /dashboard/search */ export default function SearchPage() { return ( -
+
-
- }> - - -
+ }> + +
); } diff --git a/quotevote-frontend/src/components/DateSearchBar/DateSearchBar.tsx b/quotevote-frontend/src/components/DateSearchBar/DateSearchBar.tsx new file mode 100644 index 0000000..a428c50 --- /dev/null +++ b/quotevote-frontend/src/components/DateSearchBar/DateSearchBar.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Calendar, ChevronDown } from 'lucide-react' + +/** + * DateSearchBar component + * + * Collapsible date range filter that syncs from/to values to URL search params. + */ +export default function DateSearchBar() { + const router = useRouter() + const searchParams = useSearchParams() + const [isOpen, setIsOpen] = useState(false) + + const from = searchParams.get('from') || '' + const to = searchParams.get('to') || '' + + const handleFromChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams.toString()) + if (e.target.value) { + params.set('from', e.target.value) + } else { + params.delete('from') + } + router.replace(`?${params.toString()}`) + } + + const handleToChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams.toString()) + if (e.target.value) { + params.set('to', e.target.value) + } else { + params.delete('to') + } + router.replace(`?${params.toString()}`) + } + + return ( +
+ + {isOpen && ( +
+
+ + +
+
+ + +
+
+ )} +
+ ) +} diff --git a/quotevote-frontend/src/components/Post/Post.tsx b/quotevote-frontend/src/components/Post/Post.tsx index 414ae4f..3a86502 100644 --- a/quotevote-frontend/src/components/Post/Post.tsx +++ b/quotevote-frontend/src/components/Post/Post.tsx @@ -36,49 +36,10 @@ import { } from '@/graphql/queries' import useGuestGuard from '@/hooks/useGuestGuard' import { cn } from '@/lib/utils' +import VotingBoard from '@/components/VotingComponents/VotingBoard' +import VotingPopup from '@/components/VotingComponents/VotingPopup' import type { Post, PostVote, PostProps } from '@/types/post' - -/** - * TODO: VotingBoard and VotingPopup components need to be migrated separately. - * These components handle text selection and voting functionality. - * For now, displaying post text without voting highlights. - */ -function VotingBoardPlaceholder({ - content, - children, -}: { - content: string - children?: (props: { text: string }) => React.ReactNode -}) { - return ( -
-
- {content} -
- {children && children({ text: content })} -
- ) -} - -function VotingPopupPlaceholder({ - onVote: _onVote, - onAddComment: _onAddComment, - onAddQuote: _onAddQuote, - hasVoted: _hasVoted, -}: { - onVote: (obj: { type: string; tags?: string[] }) => void - onAddComment: (comment: string, withQuote?: boolean) => void - onAddQuote: () => void - hasVoted: boolean -}) { - // Placeholder - actual VotingPopup needs to be migrated - // These props are intentionally unused until VotingPopup is migrated - void _onVote - void _onAddComment - void _onAddQuote - void _hasVoted - return null -} +import type { SelectedText, VotedByEntry, VoteType, VoteOption } from '@/types/voting' export default function Post({ post, @@ -95,15 +56,11 @@ export default function Post({ const { _followingId = [] } = user const parsedCreated = moment(created).format('LLL') - // selectedText is used in handlers, but setSelectedText is not used until VotingBoard is migrated - const [selectedText, _setSelectedText] = useState<{ - text: string - startIndex: number - endIndex: number - }>({ + const [selectedText, setSelectedText] = useState({ text: '', startIndex: 0, endIndex: 0, + points: 0, }) const [open, setOpen] = useState(false) const [openInvite, setOpenInvite] = useState(false) @@ -436,7 +393,7 @@ export default function Post({ } } - const handleVoting = async (obj: { type: string; tags?: string[] }) => { + const handleVoting = async (obj: { type: VoteType; tags: VoteOption }) => { if (!ensureAuth()) return if (hasVoted) { toast('You have already voted on this post') @@ -618,16 +575,28 @@ export default function Post({ {getUserVoteType() === 'up' ? 'upvoted' : 'downvoted'} this post
)} - - {() => ( - + {(selection) => ( + ({ + userId: v.user?._id || '', + type: (v.type as VoteType) || 'up', + _id: v._id, + }))} onVote={handleVoting} onAddComment={handleAddComment} onAddQuote={handleAddQuote} + selectedText={selection} hasVoted={hasVoted} + userVoteType={getUserVoteType() as VoteType | null} /> )} - + {user._id === userId && !post.enable_voting && ( diff --git a/quotevote-frontend/src/components/Post/PostController.tsx b/quotevote-frontend/src/components/Post/PostController.tsx index 224d115..68f4586 100644 --- a/quotevote-frontend/src/components/Post/PostController.tsx +++ b/quotevote-frontend/src/components/Post/PostController.tsx @@ -1,40 +1,83 @@ 'use client' import { useEffect } from 'react' -import { useParams } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { useQuery } from '@apollo/client/react' import { useAppStore } from '@/store' -import type { PostControllerProps } from '@/types/post' +import { GET_POST } from '@/graphql/queries' +import Post from './Post' +import PostSkeleton from './PostSkeleton' +import type { PostControllerProps, PostQueryData } from '@/types/post' /** * PostController Component - * + * * Controller component for individual post pages. - * Handles post selection and page state management. - * - * Note: This component is a placeholder. The actual PostPage component - * should be created in the app directory following Next.js App Router patterns. + * Fetches post data by ID and renders the Post component. */ -export default function PostController({ postId: propPostId }: PostControllerProps) { - const params = useParams() - const postId = propPostId || (params?.postId as string) || '' +export default function PostController({ postId }: PostControllerProps) { + const router = useRouter() + const userData = useAppStore((state) => state.user.data) const setSelectedPage = useAppStore((state) => state.setSelectedPage) + const { loading, error, data, refetch } = useQuery(GET_POST, { + variables: { postId }, + fetchPolicy: 'network-only', + skip: !postId, + }) + useEffect(() => { setSelectedPage('') }, [setSelectedPage]) - // TODO: Implement PostPage component in app directory - // This should render the actual post page with Post component - // For now, this is a placeholder that can be used as a reference - + if (!postId) { + return ( +
+

Post not found

+
+ ) + } + + if (loading) return + + if (error) { + router.push('/error') + return null + } + + if (!data?.post) { + return ( +
+

Post not found

+
+ ) + } + + const post = data.post + + // Normalize user data to match PostProps.user shape + const user = { + _id: (userData._id as string | undefined) || userData.id, + admin: userData.admin, + _followingId: Array.isArray(userData._followingId) + ? (userData._followingId as string[]) + : userData._followingId + ? [userData._followingId as string] + : [], + } + + const postActions = [ + ...(post.comments || []).map((c) => ({ ...c, __typename: 'Comment' })), + ...(post.votes || []).map((v) => ({ ...v, __typename: 'Vote' })), + ...(post.quotes || []).map((q) => ({ ...q, __typename: 'Quote' })), + ] + return ( -
-

- PostController: Post ID {postId} -
- PostPage component should be implemented in app directory -

-
+ ) } - diff --git a/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx new file mode 100644 index 0000000..31a0b7f --- /dev/null +++ b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Search as SearchIcon } from 'lucide-react' +import { useQuery } from '@apollo/client/react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { useDebounce } from '@/hooks/useDebounce' +import PostCard from '@/components/Post/PostCard' +import PostSkeleton from '@/components/Post/PostSkeleton' +import { + GET_TOP_POSTS, + GET_FEATURED_POSTS, + GET_FRIENDS_POSTS, +} from '@/graphql/queries' +import { useAppStore } from '@/store' +import SearchGuestSections from './SearchGuestSections' +import type { Post, PostsListData } from '@/types/post' + +interface FeaturedPostsData { + featuredPosts: { + entities: Post[] + pagination: { + total_count: number + limit: number + offset: number + } + } +} + +const LIMIT = 20 + +/** + * PostsTab — renders a list of posts from a query result + */ +function PostsTab({ + posts, + loading, +}: { + posts: Post[] + loading: boolean +}) { + if (loading) { + return ( +
+ + + +
+ ) + } + + if (!posts.length) { + return ( +

No posts found.

+ ) + } + + return ( +
+ {posts.map((post) => ( + + ))} +
+ ) +} + +/** + * TrendingTab — loads top posts + */ +function TrendingTab({ from, to }: { from: string; to: string }) { + const { loading, data } = useQuery(GET_TOP_POSTS, { + variables: { + limit: LIMIT, + offset: 0, + searchKey: '', + startDateRange: from || undefined, + endDateRange: to || undefined, + }, + }) + const posts: Post[] = data?.posts?.entities ?? [] + return +} + +/** + * FeaturedTab — loads featured posts + */ +function FeaturedTab({ from, to }: { from: string; to: string }) { + const { loading, data } = useQuery(GET_FEATURED_POSTS, { + variables: { + limit: LIMIT, + offset: 0, + searchKey: '', + startDateRange: from || undefined, + endDateRange: to || undefined, + }, + }) + const posts: Post[] = data?.featuredPosts?.entities ?? [] + return +} + +/** + * FriendsTab — loads posts from friends + */ +function FriendsTab({ from, to }: { from: string; to: string }) { + const { loading, data } = useQuery(GET_FRIENDS_POSTS, { + variables: { + limit: LIMIT, + offset: 0, + searchKey: '', + startDateRange: from || undefined, + endDateRange: to || undefined, + friendsOnly: true, + }, + }) + const posts: Post[] = data?.posts?.entities ?? [] + return +} + +/** + * SearchTab — loads posts matching a search query + */ +function SearchTab({ + searchKey, + from, + to, +}: { + searchKey: string + from: string + to: string +}) { + const { loading, data } = useQuery(GET_TOP_POSTS, { + variables: { + limit: LIMIT, + offset: 0, + searchKey, + startDateRange: from || undefined, + endDateRange: to || undefined, + }, + skip: !searchKey, + }) + const posts: Post[] = data?.posts?.entities ?? [] + return +} + +/** + * SearchContainer Component + * + * Full-featured search container with URL-synced tabs and debounced search input. + * Tabs: Trending | Featured | Friends | Search (only when ?q= is set) + */ +export default function SearchContainer() { + const router = useRouter() + const searchParams = useSearchParams() + const user = useAppStore((state) => state.user.data) + + const q = searchParams.get('q') || '' + const tab = searchParams.get('tab') || 'trending' + const from = searchParams.get('from') || '' + const to = searchParams.get('to') || '' + + const [inputValue, setInputValue] = useState(q) + const debouncedQuery = useDebounce(inputValue, 400) + + // Sync debounced query to URL + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()) + if (debouncedQuery) { + params.set('q', debouncedQuery) + if (params.get('tab') !== 'search') { + params.set('tab', 'search') + } + } else { + params.delete('q') + if (params.get('tab') === 'search') { + params.set('tab', 'trending') + } + } + router.replace(`?${params.toString()}`) + // Only run when debouncedQuery changes; avoid re-running on searchParams changes to prevent loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuery]) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setInputValue(e.target.value) + }, + [] + ) + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set('tab', value) + router.replace(`?${params.toString()}`) + }, + [router, searchParams] + ) + + // Determine active tab — if no query, don't show 'search' tab as active + const activeTab = q ? tab : tab === 'search' ? 'trending' : tab + const isLoggedIn = !!(user?._id || user?.id) + + return ( +
+ {/* Search input */} +
+ + +
+ + {/* Tabs */} + + + Trending + Featured + {isLoggedIn && Friends} + {q && Search} + + + + + + + + + + + {isLoggedIn && ( + + + + )} + + {q && ( + + + + )} + + + +
+ ) +} diff --git a/quotevote-frontend/src/components/SearchContainer/SearchGuestSections.tsx b/quotevote-frontend/src/components/SearchContainer/SearchGuestSections.tsx new file mode 100644 index 0000000..30806c8 --- /dev/null +++ b/quotevote-frontend/src/components/SearchContainer/SearchGuestSections.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useAppStore } from '@/store' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { useAuthModal } from '@/context/AuthModalContext' + +/** + * SearchGuestSections component + * + * Renders a sign-up CTA for unauthenticated users below the search results. + * Returns null if the user is already logged in. + */ +export default function SearchGuestSections() { + const user = useAppStore((state) => state.user.data) + const { openAuthModal } = useAuthModal() + + if (user?._id || user?.id) return null + + return ( + + +
+

Join Quote.Vote

+

+ Sign up to see trending posts, vote on quotes, and join the discussion. +

+
+
+
+
+
+
+ + + + ) +} From 0b74c6320bdbeb1460097d84f6d94efcd97c98b1 Mon Sep 17 00:00:00 2001 From: motirebuma Date: Wed, 18 Mar 2026 18:18:59 +0300 Subject: [PATCH 2/3] posts, search, voting and comments --- .../src/components/Comment/Comment.tsx | 12 ++++---- .../src/components/Post/PostCard.tsx | 4 ++- .../SearchContainer/SearchContainer.tsx | 28 ++++++++++++++++++- quotevote-frontend/src/types/post.ts | 1 + 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/quotevote-frontend/src/components/Comment/Comment.tsx b/quotevote-frontend/src/components/Comment/Comment.tsx index 471d901..eff5c25 100644 --- a/quotevote-frontend/src/components/Comment/Comment.tsx +++ b/quotevote-frontend/src/components/Comment/Comment.tsx @@ -4,8 +4,9 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { useMutation } from '@apollo/client/react' // Using /react entry point as per project pattern import { Reference } from '@apollo/client' -import { Smile, Link as LinkIcon, Trash2, Pencil } from 'lucide-react' +import { Link as LinkIcon, Trash2, Pencil } from 'lucide-react' import { CommentInput } from './' +import CommentReactions from './CommentReactions' import { Card, CardContent, @@ -18,7 +19,7 @@ import { parseCommentDate } from '@/lib/utils/momentUtils' import { useAppStore } from '@/store/useAppStore' import { toast } from 'sonner' import { DELETE_COMMENT } from '@/graphql/mutations' -import { CommentData } from '@/types/comment' +import { CommentData, Reaction } from '@/types/comment' import useGuestGuard from '@/hooks/useGuestGuard' import { cn } from '@/lib/utils' @@ -134,9 +135,10 @@ export default function Comment({ comment, postUrl, selected }: CommentProps) { {!isEditing && ( <> - + diff --git a/quotevote-frontend/src/components/Post/PostCard.tsx b/quotevote-frontend/src/components/Post/PostCard.tsx index ff8b3d8..ace4415 100644 --- a/quotevote-frontend/src/components/Post/PostCard.tsx +++ b/quotevote-frontend/src/components/Post/PostCard.tsx @@ -16,6 +16,7 @@ import AvatarDisplay from '@/components/Avatar' import { GET_GROUP } from '@/graphql/queries' import getTopPostsVoteHighlights from '@/lib/utils/getTopPostsVoteHighlights' import useGuestGuard from '@/hooks/useGuestGuard' +import HighlightText from '@/components/HighlightText/HighlightText' import type { PostCardProps } from '@/types/post' /** @@ -59,6 +60,7 @@ export default function PostCard({ messageRoom, groupId, citationUrl, + searchKey, }: PostCardProps) { const router = useRouter() const setSelectedPost = useAppStore((state) => state.setSelectedPost) @@ -169,7 +171,7 @@ export default function PostCard({

- {title || 'Untitled'} +

{groupId && ( diff --git a/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx index 31a0b7f..a282b76 100644 --- a/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx +++ b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx @@ -13,10 +13,14 @@ import { GET_TOP_POSTS, GET_FEATURED_POSTS, GET_FRIENDS_POSTS, + SEARCH_USERNAMES, } from '@/graphql/queries' import { useAppStore } from '@/store' +import DateSearchBar from '@/components/DateSearchBar/DateSearchBar' +import UsernameResults from './UsernameResults' import SearchGuestSections from './SearchGuestSections' import type { Post, PostsListData } from '@/types/post' +import type { UsernameSearchUser } from '@/types/components' interface FeaturedPostsData { featuredPosts: { @@ -37,9 +41,11 @@ const LIMIT = 20 function PostsTab({ posts, loading, + searchKey, }: { posts: Post[] loading: boolean + searchKey?: string }) { if (loading) { return ( @@ -77,6 +83,7 @@ function PostsTab({ quotes={post.quotes ?? []} messageRoom={post.messageRoom ?? undefined} groupId={post.groupId} + searchKey={searchKey} /> ))}
@@ -158,7 +165,7 @@ function SearchTab({ skip: !searchKey, }) const posts: Post[] = data?.posts?.entities ?? [] - return + return } /** @@ -215,6 +222,14 @@ export default function SearchContainer() { [router, searchParams] ) + // User search for username results dropdown + const { loading: usersLoading, data: usersData, error: usersError } = useQuery<{ + searchUser: UsernameSearchUser[] + }>(SEARCH_USERNAMES, { + variables: { query: debouncedQuery }, + skip: !debouncedQuery, + }) + // Determine active tab — if no query, don't show 'search' tab as active const activeTab = q ? tab : tab === 'search' ? 'trending' : tab const isLoggedIn = !!(user?._id || user?.id) @@ -232,8 +247,19 @@ export default function SearchContainer() { className="pl-9" aria-label="Search posts" /> + {debouncedQuery && ( + + )}
+ {/* Date range filter */} + + {/* Tabs */} diff --git a/quotevote-frontend/src/types/post.ts b/quotevote-frontend/src/types/post.ts index 8ae7344..8cd08c0 100644 --- a/quotevote-frontend/src/types/post.ts +++ b/quotevote-frontend/src/types/post.ts @@ -120,6 +120,7 @@ export interface PostCardProps { quotes?: PostQuote[] messageRoom?: PostMessageRoom groupId?: string | null + searchKey?: string } /** From 9f06aa7679942f09bbd4853a415e163c05cb2ac7 Mon Sep 17 00:00:00 2001 From: motirebuma Date: Wed, 18 Mar 2026 19:01:22 +0300 Subject: [PATCH 3/3] Fixing API issues on Posts, Search, Voting & Comments --- quotevote-frontend/.env.local | 10 +- quotevote-frontend/next.config.ts | 2 +- .../src/__tests__/app/auths/login.test.tsx | 81 ++++++---- .../src/__tests__/utils/getServerUrl.test.ts | 34 ++++- .../src/app/auths/login/PageContent.tsx | 50 +++---- .../src/app/auths/signup/PageContent.tsx | 139 ++++++++++++++---- .../src/components/Comment/Comment.tsx | 65 +++----- .../src/components/Comment/CommentInput.tsx | 83 +++-------- .../src/components/Quotes/LatestQuotes.tsx | 99 +++++++++---- quotevote-frontend/src/graphql/queries.ts | 2 +- quotevote-frontend/src/lib/auth.ts | 3 +- .../src/lib/utils/getServerUrl.ts | 26 +++- 12 files changed, 352 insertions(+), 242 deletions(-) diff --git a/quotevote-frontend/.env.local b/quotevote-frontend/.env.local index ccdbbcf..22ff76a 100644 --- a/quotevote-frontend/.env.local +++ b/quotevote-frontend/.env.local @@ -1,8 +1,8 @@ -# Quote.Vote API — production -NEXT_PUBLIC_SERVER_URL=https://api.quote.vote -NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://api.quote.vote/graphql +# Quote.Vote API — local development +NEXT_PUBLIC_SERVER_URL=http://localhost:4000 +NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql # WebSocket is auto-derived by getGraphqlWsServerUrl(): -# wss://api.quote.vote/graphql +# ws://localhost:4000/graphql # Login is a REST endpoint (not GraphQL): -# POST https://api.quote.vote/login → { username, password } → { token } +# POST http://localhost:4000/login → { username, password } → { token } diff --git a/quotevote-frontend/next.config.ts b/quotevote-frontend/next.config.ts index ee148ba..11ad349 100644 --- a/quotevote-frontend/next.config.ts +++ b/quotevote-frontend/next.config.ts @@ -56,7 +56,7 @@ const nextConfig: NextConfig = { "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", - "connect-src 'self' http://localhost:4000", + "connect-src 'self' http://localhost:4000 ws://localhost:4000", "frame-ancestors 'none'", ].join("; "), }, diff --git a/quotevote-frontend/src/__tests__/app/auths/login.test.tsx b/quotevote-frontend/src/__tests__/app/auths/login.test.tsx index 2322dbd..b4e15d6 100644 --- a/quotevote-frontend/src/__tests__/app/auths/login.test.tsx +++ b/quotevote-frontend/src/__tests__/app/auths/login.test.tsx @@ -1,6 +1,5 @@ -import { render, screen, fireEvent, waitFor, type MockedResponse } from '../../utils/test-utils' +import { render, screen, fireEvent, waitFor } from '../../utils/test-utils' import LoginPageContent from '@/app/auths/login/PageContent' -import { LOGIN_MUTATION } from '@/graphql/mutations' const mockPush = jest.fn() jest.mock('next/navigation', () => ({ @@ -13,12 +12,20 @@ jest.mock('@/store/useAppStore', () => ({ selector({ setUserData: jest.fn() }), })) +const mockLoginUser = jest.fn() +jest.mock('@/lib/auth', () => ({ + loginUser: (...args: unknown[]) => mockLoginUser(...args), + setToken: jest.fn(), + getToken: jest.fn(), + removeToken: jest.fn(), +})) + describe('LoginPageContent', () => { beforeEach(() => jest.clearAllMocks()) - it('renders email and password fields', () => { + it('renders username/email and password fields', () => { render() - expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() }) @@ -26,45 +33,55 @@ describe('LoginPageContent', () => { render() fireEvent.click(screen.getByRole('button', { name: /sign in/i })) await waitFor(() => { - expect(screen.getByText(/email is required/i)).toBeInTheDocument() + expect(screen.getByText(/username or email is required/i)).toBeInTheDocument() }) }) - it('calls mutation on valid submit', async () => { - const mocks: MockedResponse[] = [ - { - request: { - query: LOGIN_MUTATION, - variables: { username: 'test@example.com', password: 'password123' }, - }, - result: { - data: { - login: { - token: 'test-token', - user: { - _id: '1', - id: '1', - username: 'test', - email: 'test@example.com', - name: '', - avatar: '', - admin: false, - accountStatus: 'active', - }, - }, - }, + it('calls loginUser on valid submit and redirects', async () => { + mockLoginUser.mockResolvedValue({ + success: true, + data: { + user: { + _id: '1', + username: 'test', + email: 'test@example.com', }, + token: 'test-token', }, - ] - render(, { mocks }) - fireEvent.change(screen.getByLabelText(/email/i), { + }) + + render() + fireEvent.change(screen.getByLabelText(/username or email/i), { target: { value: 'test@example.com' }, }) fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'password123' }, }) fireEvent.click(screen.getByRole('button', { name: /sign in/i })) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/dashboard/search')) + await waitFor(() => { + expect(mockLoginUser).toHaveBeenCalledWith('test@example.com', 'password123') + expect(mockPush).toHaveBeenCalledWith('/dashboard/search') + }) + }) + + it('shows error toast on login failure', async () => { + const { toast } = jest.requireMock('sonner') + mockLoginUser.mockResolvedValue({ + success: false, + error: 'Invalid username or password.', + }) + + render() + fireEvent.change(screen.getByLabelText(/username or email/i), { + target: { value: 'bad@example.com' }, + }) + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'wrongpass' }, + }) + fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Invalid username or password.') + }) }) it('renders forgot password link', () => { diff --git a/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts b/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts index dec0cbc..9edbdda 100644 --- a/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts +++ b/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts @@ -5,20 +5,42 @@ describe('getServerUrl', () => { beforeEach(() => { jest.resetModules() process.env = { ...OLD_ENV } + // Clear env vars so tests control them + delete process.env.NEXT_PUBLIC_SERVER_URL + delete process.env.NEXT_PUBLIC_SERVER + delete process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT }) afterAll(() => { process.env = OLD_ENV }) - it('respects NEXT_PUBLIC_SERVER env var', () => { - process.env.NEXT_PUBLIC_SERVER = 'https://env.example.com' + it('respects NEXT_PUBLIC_SERVER_URL env var', () => { + process.env.NEXT_PUBLIC_SERVER_URL = 'https://env.example.com' expect(getBaseServerUrl()).toBe('https://env.example.com') }) + it('falls back to NEXT_PUBLIC_SERVER env var', () => { + process.env.NEXT_PUBLIC_SERVER = 'https://legacy.example.com' + expect(getBaseServerUrl()).toBe('https://legacy.example.com') + }) + + it('derives base URL from NEXT_PUBLIC_GRAPHQL_ENDPOINT', () => { + process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT = 'https://api.example.com/graphql' + expect(getBaseServerUrl()).toBe('https://api.example.com') + }) + it('builds graphql urls', () => { - const base = getBaseServerUrl() - expect(getGraphqlServerUrl()).toBe(`${base}/graphql`) - const ws = getGraphqlWsServerUrl() - expect(ws).toContain('/graphql') + process.env.NEXT_PUBLIC_SERVER_URL = 'https://api.example.com' + expect(getGraphqlServerUrl()).toBe('https://api.example.com/graphql') + }) + + it('builds websocket urls for production', () => { + process.env.NEXT_PUBLIC_SERVER_URL = 'https://api.example.com' + expect(getGraphqlWsServerUrl()).toBe('wss://api.example.com/graphql') + }) + + it('builds websocket urls for localhost', () => { + process.env.NEXT_PUBLIC_SERVER_URL = 'http://localhost:4000' + expect(getGraphqlWsServerUrl()).toBe('ws://localhost:4000/graphql') }) }) diff --git a/quotevote-frontend/src/app/auths/login/PageContent.tsx b/quotevote-frontend/src/app/auths/login/PageContent.tsx index 74d75ba..c82021a 100644 --- a/quotevote-frontend/src/app/auths/login/PageContent.tsx +++ b/quotevote-frontend/src/app/auths/login/PageContent.tsx @@ -1,43 +1,29 @@ 'use client' +import { useState } from 'react' import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' -import { useMutation } from '@apollo/client/react' -import { useFormStatus } from 'react-dom' import { toast } from 'sonner' import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { LOGIN_MUTATION } from '@/graphql/mutations' -import { setToken } from '@/lib/auth' +import { loginUser } from '@/lib/auth' import { useAppStore } from '@/store/useAppStore' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { replaceGqlError } from '@/lib/utils/replaceGqlError' -import type { AuthUser } from '@/types/auth' const loginSchema = z.object({ - email: z.string().min(1, 'Email is required').email('Invalid email'), + email: z.string().min(1, 'Username or email is required'), password: z.string().min(1, 'Password is required'), }) type LoginFormData = z.infer -function SubmitButton() { - const { pending } = useFormStatus() - return ( - - ) -} - export default function LoginPageContent() { const router = useRouter() const setUserData = useAppStore((s) => s.setUserData) - const [loginMutation] = useMutation(LOGIN_MUTATION) + const [submitting, setSubmitting] = useState(false) const { register, handleSubmit, @@ -47,18 +33,19 @@ export default function LoginPageContent() { }) const onSubmit = async (values: LoginFormData) => { + setSubmitting(true) try { - const { data } = await loginMutation({ - variables: { username: values.email, password: values.password }, - }) - const result = (data as { login?: { token: string; user: AuthUser } } | undefined)?.login - if (result?.token) { - setToken(result.token) - setUserData(result.user as unknown as Record) + const result = await loginUser(values.email, values.password) + if (result.success && result.data) { + setUserData(result.data.user as Record) router.push('/dashboard/search') + } else { + toast.error(result.error || 'Login failed') } - } catch (error) { - toast.error(replaceGqlError(error instanceof Error ? error.message : 'Login failed')) + } catch { + toast.error('Connection failed. Please try again.') + } finally { + setSubmitting(false) } } @@ -70,8 +57,8 @@ export default function LoginPageContent() {
- - + + {errors.email &&

{errors.email.message}

}
@@ -84,7 +71,10 @@ export default function LoginPageContent() { /> {errors.password &&

{errors.password.message}

}
- +
diff --git a/quotevote-frontend/src/app/auths/signup/PageContent.tsx b/quotevote-frontend/src/app/auths/signup/PageContent.tsx index 830ff87..39de6a4 100644 --- a/quotevote-frontend/src/app/auths/signup/PageContent.tsx +++ b/quotevote-frontend/src/app/auths/signup/PageContent.tsx @@ -1,19 +1,34 @@ 'use client' -import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' -import { useMutation } from '@apollo/client/react' -import { useFormStatus } from 'react-dom' +import { useMutation, useQuery } from '@apollo/client/react' import { toast } from 'sonner' import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { SIGNUP_MUTATION } from '@/graphql/mutations' +import { UPDATE_USER } from '@/graphql/mutations' +import { VERIFY_PASSWORD_RESET_TOKEN } from '@/graphql/queries' +import { setToken } from '@/lib/auth' +import { useAppStore } from '@/store/useAppStore' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { replaceGqlError } from '@/lib/utils/replaceGqlError' +import { Skeleton } from '@/components/ui/skeleton' + +interface VerifyTokenData { + verifyUserPasswordResetToken: { + _id: string + username: string + email: string + } | null +} + +interface UpdateUserData { + updateUser: Record +} const signupSchema = z .object({ @@ -39,19 +54,23 @@ const signupSchema = z type SignupFormData = z.infer -function SubmitButton() { - const { pending } = useFormStatus() - return ( - - ) -} - export default function SignupPageContent() { const router = useRouter() - const [signupMutation] = useMutation(SIGNUP_MUTATION) + const searchParams = useSearchParams() + const token = searchParams.get('token') || '' + const setUserData = useAppStore((s) => s.setUserData) + const [submitting, setSubmitting] = useState(false) + + // Verify the invite token + const { data: tokenData, loading: tokenLoading, error: tokenError } = useQuery(VERIFY_PASSWORD_RESET_TOKEN, { + variables: { token }, + skip: !token, + }) + + const verifiedUser = tokenData?.verifyUserPasswordResetToken + + const [updateUser] = useMutation(UPDATE_USER) + const { register, handleSubmit, @@ -61,21 +80,84 @@ export default function SignupPageContent() { }) const onSubmit = async (values: SignupFormData) => { + setSubmitting(true) try { - await signupMutation({ - variables: { - username: values.username, - email: values.email, - password: values.password, - }, - }) - toast.success('Account created! Please sign in.') - router.push('/auths/login') + if (token && verifiedUser) { + // Invite-based signup: update the existing user via GraphQL + setToken(token) + const result = await updateUser({ + variables: { + user: { + _id: verifiedUser._id, + email: values.email, + name: '', + username: values.username, + password: values.password, + }, + }, + }) + if (result.data?.updateUser) { + setUserData(result.data.updateUser as Record) + toast.success('Account set up! Redirecting...') + router.push('/dashboard/search') + return + } + } else { + // Direct signup via REST /register endpoint + const { env } = await import('@/config/env') + const response = await fetch(`${env.serverUrl}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: values.username, + email: values.email, + username: values.username, + password: values.password, + status: 'active', + }), + }) + const data = await response.json() + if (!response.ok) { + toast.error(data?.error_message || data?.message || 'Signup failed') + return + } + toast.success('Account created! Please sign in.') + router.push('/auths/login') + } } catch (error) { - toast.error(replaceGqlError(error instanceof Error ? error.message : 'Signup failed')) + toast.error(error instanceof Error ? error.message : 'Signup failed') + } finally { + setSubmitting(false) } } + // If token is provided, show loading while verifying + if (token && tokenLoading) { + return ( +
+ + + + +
+ ) + } + + // If token provided but invalid + if (token && !tokenLoading && (!verifiedUser || tokenError)) { + return ( +
+

Invalid Invite

+

+ This invite link is invalid or has expired. +

+ + Request a new invite + +
+ ) + } + return (
@@ -119,7 +201,10 @@ export default function SignupPageContent() {

{errors.confirmPassword.message}

)}
- +

Already have an account?{' '} diff --git a/quotevote-frontend/src/components/Comment/Comment.tsx b/quotevote-frontend/src/components/Comment/Comment.tsx index eff5c25..454d029 100644 --- a/quotevote-frontend/src/components/Comment/Comment.tsx +++ b/quotevote-frontend/src/components/Comment/Comment.tsx @@ -1,11 +1,10 @@ "use client" -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { useMutation } from '@apollo/client/react' // Using /react entry point as per project pattern import { Reference } from '@apollo/client' -import { Link as LinkIcon, Trash2, Pencil } from 'lucide-react' -import { CommentInput } from './' +import { Link as LinkIcon, Trash2 } from 'lucide-react' import CommentReactions from './CommentReactions' import { Card, @@ -44,8 +43,6 @@ export default function Comment({ comment, postUrl, selected }: CommentProps) { _id, } = comment const { username, avatar } = commentUser - const [isEditing, setIsEditing] = useState(false) - const router = useRouter() const parsedDate = parseCommentDate(new Date(created)) @@ -119,50 +116,26 @@ export default function Comment({ comment, postUrl, selected }: CommentProps) { - {isEditing ? ( - setIsEditing(false)} - onSuccess={() => setIsEditing(false)} - /> - ) : ( -

{content}

- )} +

{content}

- {!isEditing && ( - <> - - - {isOwner && ( - <> - - - - )} - + + + {isOwner && ( + )} diff --git a/quotevote-frontend/src/components/Comment/CommentInput.tsx b/quotevote-frontend/src/components/Comment/CommentInput.tsx index 7982d25..766fe61 100644 --- a/quotevote-frontend/src/components/Comment/CommentInput.tsx +++ b/quotevote-frontend/src/components/Comment/CommentInput.tsx @@ -6,7 +6,7 @@ import { useMutation } from '@apollo/client/react' import { gql } from '@apollo/client' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import { ADD_COMMENT, UPDATE_COMMENT } from '@/graphql/mutations' +import { ADD_COMMENT } from '@/graphql/mutations' import { useAppStore } from '@/store/useAppStore' import { toast } from 'sonner' import useGuestGuard from '@/hooks/useGuestGuard' @@ -14,9 +14,6 @@ import { cn } from '@/lib/utils' interface CommentInputProps { actionId: string // The ID of the post/action being commented on - commentId?: string // If provided, we are editing this comment - initialContent?: string - onCancel?: () => void onSuccess?: () => void } @@ -34,29 +31,19 @@ interface AddCommentData { } } -interface UpdateCommentData { - updateComment: { - _id: string - content: string - } -} - -export default function CommentInput({ - actionId, - commentId, - initialContent = '', - onCancel, - onSuccess +export default function CommentInput({ + actionId, + onSuccess }: CommentInputProps) { - const [content, setContent] = useState(initialContent) + const [content, setContent] = useState('') const [isFocused, setIsFocused] = useState(false) const userId = useAppStore((state) => state.user.data.id || state.user.data._id) const ensureAuth = useGuestGuard() - const [addComment, { loading: adding }] = useMutation(ADD_COMMENT, { + const [addComment, { loading }] = useMutation(ADD_COMMENT, { update(cache, { data }) { if (!data?.addComment) return - + cache.modify({ fields: { comments(existing = []) { @@ -83,48 +70,31 @@ export default function CommentInput({ } }) - const [updateComment, { loading: updating }] = useMutation(UPDATE_COMMENT) - const loading = adding || updating - const handleSubmit = async () => { if (!content.trim()) return if (!ensureAuth()) return try { - if (commentId) { - // Edit mode - await updateComment({ - variables: { - commentId, + await addComment({ + variables: { + comment: { + actionId, + userId, content } - }) - toast.success('Comment updated!') - } else { - // Create mode - await addComment({ - variables: { - comment: { - actionId, - userId, - content - } - } - }) - toast.success('Comment posted!') - setContent('') - } + } + }) + toast.success('Comment posted!') + setContent('') if (onSuccess) onSuccess() } catch (err: unknown) { toast.error(`Error: ${(err as Error).message}`) } } - const isEdgeCase = commentId && initialContent // Only for edit - return (