diff --git a/.gitignore b/.gitignore index 291adc3b..47fd92ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ quotevote-backend/compose.yml quotevote-backend/dockerfile session_state.md -graphify-out/ \ No newline at end of file +graphify-out/ + +# Local Cursor IDE rules / agent config (machine-specific) +.cursor/ diff --git a/quotevote-backend/app/server.ts b/quotevote-backend/app/server.ts index b7861f0f..144df1b3 100644 --- a/quotevote-backend/app/server.ts +++ b/quotevote-backend/app/server.ts @@ -112,8 +112,8 @@ async function startServer() { getBuddyList: [BuddyWithPresence!]! getRoster: [Roster!]! - # Action reactions - actionReactions(actionId: ID!): [Reaction!]! + # Action reactions (String! — matches hosted/legacy API; ID! breaks clients) + actionReactions(actionId: String!): [Reaction!]! # Admin / reports getBotReportedUsers(sortBy: String, limit: Int): [User!]! diff --git a/quotevote-frontend/.env.example b/quotevote-frontend/.env.example new file mode 100644 index 00000000..8101b386 --- /dev/null +++ b/quotevote-frontend/.env.example @@ -0,0 +1,14 @@ +# Quote.Vote API — local development +# Copy to `.env.local` and adjust for your machine. Never commit `.env.local`. +NEXT_PUBLIC_SERVER_URL=http://localhost:4000 +NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql + +# Optional: point at hosted staging/production instead of local backend +# NEXT_PUBLIC_SERVER_URL=https://api.quote.vote +# NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://api.quote.vote/graphql + +# WebSocket is auto-derived by getGraphqlWsServerUrl(): +# ws://localhost:4000/graphql (local) +# wss://api.quote.vote/graphql (hosted) +# Login is a REST endpoint (not GraphQL): +# POST {NEXT_PUBLIC_SERVER_URL}/auth/login → { username, password } → { token } diff --git a/quotevote-frontend/.env.local b/quotevote-frontend/.env.local deleted file mode 100644 index 22ff76ac..00000000 --- a/quotevote-frontend/.env.local +++ /dev/null @@ -1,8 +0,0 @@ -# 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(): -# ws://localhost:4000/graphql -# Login is a REST endpoint (not GraphQL): -# POST http://localhost:4000/login → { username, password } → { token } diff --git a/quotevote-frontend/.gitignore b/quotevote-frontend/.gitignore index aab38bba..80386b8d 100644 --- a/quotevote-frontend/.gitignore +++ b/quotevote-frontend/.gitignore @@ -38,6 +38,9 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env +.env*.local +!.env.example +!.env.e2e.example # vercel .vercel diff --git a/quotevote-frontend/README.md b/quotevote-frontend/README.md index 44edbf6b..f4945aa1 100644 --- a/quotevote-frontend/README.md +++ b/quotevote-frontend/README.md @@ -58,14 +58,19 @@ The application will be available at [http://localhost:3000](http://localhost:30 ### Environment Variables -Create a `.env.local` file in the root directory with the following variable: +Copy the example env file and adjust as needed: + +```bash +cp .env.example .env.local +``` ```env # GraphQL endpoint URL (must be prefixed with NEXT_PUBLIC_) +NEXT_PUBLIC_SERVER_URL=http://localhost:4000 NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql ``` -The server URL is automatically derived from the GraphQL endpoint by removing the `/graphql` suffix. +`.env.local` is gitignored — keep machine-specific overrides (including hosted API URLs) there only. ## 🎯 Path Aliases diff --git a/quotevote-frontend/e2e/featured-posts.spec.ts b/quotevote-frontend/e2e/featured-posts.spec.ts new file mode 100644 index 00000000..c9a7a2f6 --- /dev/null +++ b/quotevote-frontend/e2e/featured-posts.spec.ts @@ -0,0 +1,144 @@ +/** + * RC1-004 — Featured posts should navigate to post detail page + * + * Clicking a featured post card on the homepage should open the + * corresponding post detail route. + */ +import { test, expect, type Page } from "@playwright/test"; + +const FEATURED_POSTS = [ + { + __typename: "Post", + _id: "feat-e2e-1", + userId: "user-1", + groupId: "general", + title: "E2E Featured Post One", + text: "First featured post body used for navigation regression coverage.", + upvotes: 5, + downvotes: 0, + bookmarkedBy: [], + created: new Date().toISOString(), + url: "/post/general/e2e-featured-post-one/feat-e2e-1", + citationUrl: null, + creator: { + __typename: "User", + _id: "user-1", + name: "Featured Author", + username: "featured_author", + avatar: null, + contributorBadge: false, + }, + votes: [], + comments: [{ __typename: "Comment", _id: "c1" }], + quotes: [], + messageRoom: null, + }, + { + __typename: "Post", + _id: "feat-e2e-2", + userId: "user-2", + groupId: "climate", + title: "E2E Featured Post Two", + text: "Second featured post body used for navigation regression coverage.", + upvotes: 3, + downvotes: 1, + bookmarkedBy: [], + created: new Date().toISOString(), + url: "/post/climate/e2e-featured-post-two/feat-e2e-2", + citationUrl: null, + creator: { + __typename: "User", + _id: "user-2", + name: "Second Author", + username: "second_author", + avatar: null, + contributorBadge: false, + }, + votes: [], + comments: [], + quotes: [{ __typename: "Quote", _id: "q1" }], + messageRoom: null, + }, +] as const; + +async function mockFeaturedPostsGraphQL(page: Page) { + await page.route("**/graphql", async (route) => { + const request = route.request(); + if (request.method() !== "POST") { + await route.fallback(); + return; + } + + let query = ""; + try { + const payload = request.postDataJSON() as { query?: string } | null; + query = payload?.query || ""; + } catch { + await route.fallback(); + return; + } + + if (!query.includes("featuredPosts")) { + await route.fallback(); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + featuredPosts: { + __typename: "Posts", + entities: FEATURED_POSTS, + pagination: { + __typename: "Pagination", + total_count: FEATURED_POSTS.length, + limit: 10, + offset: 0, + }, + }, + }, + }), + }); + }); +} + +test.describe("RC1-004 Featured posts navigate to post detail", () => { + // Homepage featured posts are for logged-out visitors; clear stored auth. + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await mockFeaturedPostsGraphQL(page); + }); + + test("clicking each featured post card opens the corresponding post page", async ({ + page, + }) => { + await page.goto("/"); + + const cards = page.getByTestId("featured-post-card"); + await expect(cards).toHaveCount(FEATURED_POSTS.length); + + for (const post of FEATURED_POSTS) { + await page.goto("/"); + await expect(cards).toHaveCount(FEATURED_POSTS.length); + + const card = page.getByTestId("featured-post-card").filter({ + hasText: post.title, + }); + await expect(card).toBeVisible(); + await expect(card).toHaveAttribute( + "href", + `/dashboard${post.url.replace(/\?/g, "")}` + ); + + await card.click(); + await expect(page).toHaveURL(new RegExp(`/dashboard${post.url}$`)); + + await page.goBack(); + await expect(page).toHaveURL(/\/$/); + await expect(cards.first()).toBeVisible(); + } + }); +}); diff --git a/quotevote-frontend/src/__tests__/app/page.test.tsx b/quotevote-frontend/src/__tests__/app/page.test.tsx index 7f58db1b..80a9a6c8 100644 --- a/quotevote-frontend/src/__tests__/app/page.test.tsx +++ b/quotevote-frontend/src/__tests__/app/page.test.tsx @@ -544,6 +544,89 @@ describe('LandingPage', () => { }); }); + // ── Featured posts navigation (RC1-004) ──────────────────── + + describe('Featured posts navigation', () => { + const featuredPosts = [ + { + _id: 'feat-1', + userId: 'u1', + title: 'Featured Democracy Debate', + text: 'A thoughtful take on civic discourse.', + created: new Date().toISOString(), + url: '/post/general/featured-democracy-debate/feat-1', + upvotes: 12, + downvotes: 1, + creator: { _id: 'u1', name: 'Alex Author', username: 'alex' }, + comments: [{ _id: 'c1' }], + quotes: [], + }, + { + _id: 'feat-2', + userId: 'u2', + title: 'Climate Action Now', + text: 'We need practical climate policy.', + created: new Date().toISOString(), + url: '/post/climate/climate-action-now/feat-2', + upvotes: 8, + downvotes: 0, + creator: { _id: 'u2', name: 'Sam Writer', username: 'sam' }, + comments: [], + quotes: [{ _id: 'q1' }], + }, + ]; + + beforeEach(() => { + mockUseQuery.mockReturnValue({ + loading: false, + error: undefined, + data: { + featuredPosts: { + entities: featuredPosts, + pagination: { total_count: 2, limit: 10, offset: 0 }, + }, + posts: { entities: [] }, + searchUser: [], + }, + }); + }); + + it('renders featured post cards as links to post detail routes', () => { + renderLandingPage(); + + expect( + screen.getByRole('heading', { name: /featured posts/i }) + ).toBeInTheDocument(); + + const firstCard = screen.getByRole('link', { + name: /read featured post: featured democracy debate/i, + }); + expect(firstCard).toHaveAttribute( + 'href', + '/dashboard/post/general/featured-democracy-debate/feat-1' + ); + + const secondCard = screen.getByRole('link', { + name: /read featured post: climate action now/i, + }); + expect(secondCard).toHaveAttribute( + 'href', + '/dashboard/post/climate/climate-action-now/feat-2' + ); + }); + + it('makes the full card the clickable navigation target', () => { + renderLandingPage(); + + const cards = screen.getAllByTestId('featured-post-card'); + expect(cards).toHaveLength(2); + cards.forEach((card) => { + expect(card.tagName.toLowerCase()).toBe('a'); + expect(card).toHaveAttribute('href'); + }); + }); + }); + // ── Auth redirect ────────────────────────────────────────── describe('Auth redirect', () => { diff --git a/quotevote-frontend/src/app/components/LandingPage/LandingPageContent.tsx b/quotevote-frontend/src/app/components/LandingPage/LandingPageContent.tsx index fc18e82c..a96902a4 100644 --- a/quotevote-frontend/src/app/components/LandingPage/LandingPageContent.tsx +++ b/quotevote-frontend/src/app/components/LandingPage/LandingPageContent.tsx @@ -1971,6 +1971,10 @@ function FeaturedPostCard({ post, timeAgo }: { post: Post; timeAgo: string }) { const downvotes = post.downvotes ?? 0; const commentCount = post.comments?.length || 0; const quoteCount = post.quotes?.length || 0; + const href = post.url ? toAppPostUrl(post.url) : undefined; + const ariaLabel = post.title + ? `Read featured post: ${post.title}` + : 'Read featured post'; const displayText = post.text ? post.text.length > 200 @@ -1978,98 +1982,125 @@ function FeaturedPostCard({ post, timeAgo }: { post: Post; timeAgo: string }) { : post.text : ''; - return ( -
{creatorName}
-- @{username} · {timeAgo} -
-+ {creatorName} +
++ @{username} · {timeAgo} +
- {displayText} -
- )} + {/* Body */} + {displayText && ( ++ {displayText} +
+ )} - {/* Engagement stats */} + {/* Engagement stats */} +