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__/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 }) => (
),
}));
+// 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/__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 (
-
- {pending && }
- Sign In
-
- )
-}
-
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() {
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 (
-
- {pending && }
- Create Account
-
- )
-}
-
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}
)}
-
+
+ {submitting && }
+ Create Account
+
Already have an account?{' '}
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 (
- );
+ )
}
- 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/Comment/Comment.tsx b/quotevote-frontend/src/components/Comment/Comment.tsx
index 471d901..454d029 100644
--- a/quotevote-frontend/src/components/Comment/Comment.tsx
+++ b/quotevote-frontend/src/components/Comment/Comment.tsx
@@ -1,11 +1,11 @@
"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 { Smile, 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,
CardContent,
@@ -18,7 +18,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'
@@ -43,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))
@@ -118,49 +116,26 @@ export default function Comment({ comment, postUrl, selected }: CommentProps) {
- {isEditing ? (
- setIsEditing(false)}
- onSuccess={() => setIsEditing(false)}
- />
- ) : (
- {content}
- )}
+ {content}
- {!isEditing && (
- <>
-
-
-
-
-
-
- {isOwner && (
- <>
- setIsEditing(true)}
- >
-
-
-
-
-
- >
- )}
- >
+
+
+
+
+ {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 (
)}
-
- {() => (
-
+ {(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/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/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 (
+
+ )
+ }
+
+ if (loading) return
+
+ if (error) {
+ router.push('/error')
+ return null
+ }
+
+ if (!data?.post) {
+ return (
+
+ )
+ }
+
+ 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/Quotes/LatestQuotes.tsx b/quotevote-frontend/src/components/Quotes/LatestQuotes.tsx
index 5ec49fb..84a1306 100644
--- a/quotevote-frontend/src/components/Quotes/LatestQuotes.tsx
+++ b/quotevote-frontend/src/components/Quotes/LatestQuotes.tsx
@@ -1,46 +1,94 @@
'use client'
-import { useEffect, useState, startTransition } from 'react'
import { useQuery } from '@apollo/client/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { GET_LATEST_QUOTES } from '@/graphql/queries'
-import type { LatestQuotesProps, Quote } from '@/types/components'
+import { GET_TOP_POSTS } from '@/graphql/queries'
+import type { LatestQuotesProps } from '@/types/components'
+interface QuoteData {
+ _id: string
+ quote?: string
+ startWordIndex?: number
+ endWordIndex?: number
+ user?: {
+ _id: string
+ username: string
+ }
+}
+
+interface PostEntity {
+ _id: string
+ title: string
+ quotes?: QuoteData[]
+}
+
+interface TopPostsResponse {
+ posts: {
+ entities: PostEntity[]
+ }
+}
+
+/**
+ * LatestQuotes sidebar widget.
+ * Fetches recent posts and extracts their quotes to display.
+ */
export function LatestQuotes({ limit = 5 }: LatestQuotesProps) {
- const [quotes, setQuotes] = useState([])
- const { data } = useQuery(GET_LATEST_QUOTES, {
- variables: { limit },
- pollInterval: 3000,
- fetchPolicy: 'network-only',
+ const { data } = useQuery(GET_TOP_POSTS, {
+ variables: {
+ limit: 10,
+ offset: 0,
+ searchKey: '',
+ },
+ fetchPolicy: 'cache-first',
})
- useEffect(() => {
- if (data && (data as { latestQuotes?: Quote[] }).latestQuotes) {
- const latestQuotes = (data as { latestQuotes: Quote[] }).latestQuotes
- startTransition(() => {
- setQuotes((prev) => {
- const existingIds = prev.map((q) => q._id)
- const fresh = latestQuotes.filter(
- (q: Quote) => !existingIds.includes(q._id)
- )
- return [...fresh, ...prev]
- })
- })
+ // Extract quotes from the fetched posts
+ const quotes: (QuoteData & { postTitle: string })[] = []
+ if (data?.posts?.entities) {
+ for (const post of data.posts.entities) {
+ if (post.quotes) {
+ for (const q of post.quotes) {
+ if (q.quote || q._id) {
+ quotes.push({ ...q, postTitle: post.title })
+ }
+ }
+ }
+ if (quotes.length >= limit) break
}
- }, [data])
+ }
+
+ const displayQuotes = quotes.slice(0, limit)
+
+ if (!displayQuotes.length) {
+ return (
+
+
+ Latest Quotes
+
+
+ No quotes yet.
+
+
+ )
+ }
return (
- Latest Quotes
+ Latest Quotes
@@ -48,4 +96,3 @@ export function LatestQuotes({ limit = 5 }: LatestQuotesProps) {
)
}
-
diff --git a/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx
new file mode 100644
index 0000000..a282b76
--- /dev/null
+++ b/quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx
@@ -0,0 +1,296 @@
+'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,
+ 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: {
+ 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,
+ searchKey,
+}: {
+ posts: Post[]
+ loading: boolean
+ searchKey?: string
+}) {
+ 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]
+ )
+
+ // 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)
+
+ return (
+
+ {/* Search input */}
+
+
+
+ {debouncedQuery && (
+
+ )}
+
+
+ {/* Date range filter */}
+
+
+ {/* 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.
+
+
+
+
+ Sign up to read more
+
+
+
+ )
+}
diff --git a/quotevote-frontend/src/graphql/queries.ts b/quotevote-frontend/src/graphql/queries.ts
index ab6c199..ef4b5b5 100644
--- a/quotevote-frontend/src/graphql/queries.ts
+++ b/quotevote-frontend/src/graphql/queries.ts
@@ -628,7 +628,7 @@ export const GET_CHAT_ROOMS = gql`
*/
export const SEARCH_USERNAMES = gql`
query searchUsernames($query: String!) {
- searchUser(query: $query) {
+ searchUser(queryName: $query) {
_id
username
name
diff --git a/quotevote-frontend/src/lib/auth.ts b/quotevote-frontend/src/lib/auth.ts
index 7d778e8..d7a5434 100644
--- a/quotevote-frontend/src/lib/auth.ts
+++ b/quotevote-frontend/src/lib/auth.ts
@@ -7,6 +7,7 @@
import { jwtDecode } from 'jwt-decode';
import type { LoginResponse } from '@/types/login';
+import { env } from '@/config/env';
const TOKEN_KEY = 'token';
const COOKIE_NAME = 'qv-token';
@@ -48,7 +49,7 @@ export async function loginUser(
password: string
): Promise {
try {
- const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL;
+ const serverUrl = env.serverUrl;
const response = await fetch(`${serverUrl}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
diff --git a/quotevote-frontend/src/lib/utils/getServerUrl.ts b/quotevote-frontend/src/lib/utils/getServerUrl.ts
index 6d42dba..6ae667e 100644
--- a/quotevote-frontend/src/lib/utils/getServerUrl.ts
+++ b/quotevote-frontend/src/lib/utils/getServerUrl.ts
@@ -1,17 +1,26 @@
export const getBaseServerUrl = (): string => {
- let effectiveUrl = 'https://api.quote.vote'
+ // 1. Priority: Check NEXT_PUBLIC_SERVER_URL (the validated env var)
+ try {
+ if (typeof process !== 'undefined' && process.env) {
+ const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || process.env.NEXT_PUBLIC_SERVER
+ if (serverUrl) {
+ return serverUrl
+ }
+ }
+ } catch (_e) {
+ // ignore env access errors in non-Node environments
+ }
- // 1. Priority: Check process.env (allows manual override in Netlify UI)
+ // 2. Check NEXT_PUBLIC_GRAPHQL_ENDPOINT and strip /graphql
try {
- if (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_SERVER) {
- effectiveUrl = process.env.NEXT_PUBLIC_SERVER
- return effectiveUrl
+ if (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT) {
+ return process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT.replace(/\/graphql\/?$/, '')
}
} catch (_e) {
// ignore env access errors in non-Node environments
}
- // 2. Fallback: Use window.location to detect Netlify deploy preview
+ // 3. Fallback: Use window.location to detect Netlify deploy preview
const currentUrl = typeof window !== 'undefined' ? window.location.origin : ''
if (currentUrl && currentUrl.includes('deploy-preview')) {
@@ -20,10 +29,11 @@ export const getBaseServerUrl = (): string => {
const prMatch = currentUrl.match(/deploy-preview-(\d+)/)
if (prMatch && prMatch[1]) {
const PR_NUMBER = prMatch[1]
- effectiveUrl = `https://quotevote-api-quotevote-monorepo-pr-${PR_NUMBER}.up.railway.app`
+ return `https://quotevote-api-quotevote-monorepo-pr-${PR_NUMBER}.up.railway.app`
}
}
- return effectiveUrl
+
+ return 'https://api.quote.vote'
}
export const getGraphqlServerUrl = (): string => {
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
}
/**