diff --git a/backend/apps/github/api/internal/queries/organization.py b/backend/apps/github/api/internal/queries/organization.py
index 996b287b62..902702bb2f 100644
--- a/backend/apps/github/api/internal/queries/organization.py
+++ b/backend/apps/github/api/internal/queries/organization.py
@@ -1,10 +1,14 @@
"""GitHub organization GraphQL queries."""
import strawberry
+import strawberry_django
+from apps.common.utils import normalize_limit
from apps.github.api.internal.nodes.organization import OrganizationNode
from apps.github.models.organization import Organization
+MAX_LIMIT = 100
+
@strawberry.type
class OrganizationQuery:
@@ -29,3 +33,21 @@ def organization(
return Organization.objects.get(is_owasp_related_organization=True, login=login)
except Organization.DoesNotExist:
return None
+
+ @strawberry_django.field
+ def recent_organizations(self, limit: int = 5) -> list[OrganizationNode]:
+ """Resolve recent organizations.
+
+ Args:
+ limit (int): Maximum number of organizations to return.
+
+ Returns:
+ list: List of recent organizations.
+
+ """
+ if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None:
+ return []
+
+ return Organization.objects.filter(is_owasp_related_organization=True).order_by(
+ "-created_at"
+ )[:normalized_limit]
diff --git a/backend/tests/apps/github/api/internal/queries/organization_test.py b/backend/tests/apps/github/api/internal/queries/organization_test.py
index 0b439bff17..5d15c9d0cb 100644
--- a/backend/tests/apps/github/api/internal/queries/organization_test.py
+++ b/backend/tests/apps/github/api/internal/queries/organization_test.py
@@ -48,3 +48,35 @@ def test_organization_with_different_login(self, mock_get, mock_organization):
assert result == mock_organization
mock_get.assert_called_once_with(is_owasp_related_organization=True, login="test-org")
+
+ @patch("apps.github.models.organization.Organization.objects.filter")
+ def test_recent_organizations(self, mock_filter):
+ """Test fetching recent organizations."""
+ mock_qs = Mock()
+ mock_ordered_qs = Mock()
+ mock_filter.return_value = mock_qs
+ mock_qs.order_by.return_value = mock_ordered_qs
+ mock_ordered_qs.__getitem__ = Mock(return_value=["org1", "org2"])
+
+ result = OrganizationQuery().recent_organizations(limit=2)
+
+ assert result == ["org1", "org2"]
+ mock_filter.assert_called_once_with(is_owasp_related_organization=True)
+ mock_qs.order_by.assert_called_once_with("-created_at")
+ mock_ordered_qs.__getitem__.assert_called_once_with(slice(None, 2))
+
+ @patch("apps.github.models.organization.Organization.objects.filter")
+ def test_recent_organizations_with_zero_limit(self, mock_filter):
+ """Test fetching recent organizations with zero limit returns empty list."""
+ result = OrganizationQuery().recent_organizations(limit=0)
+
+ assert result == []
+ mock_filter.assert_not_called()
+
+ @patch("apps.github.models.organization.Organization.objects.filter")
+ def test_recent_organizations_with_negative_limit(self, mock_filter):
+ """Test fetching recent organizations with negative limit returns empty list."""
+ result = OrganizationQuery().recent_organizations(limit=-1)
+
+ assert result == []
+ mock_filter.assert_not_called()
diff --git a/frontend/__tests__/a11y/pages/Community.a11y.test.tsx b/frontend/__tests__/a11y/pages/Community.a11y.test.tsx
new file mode 100644
index 0000000000..36d4862e8b
--- /dev/null
+++ b/frontend/__tests__/a11y/pages/Community.a11y.test.tsx
@@ -0,0 +1,45 @@
+import { useQuery } from '@apollo/client/react'
+import { mockCommunityGraphQLData } from '@mockData/mockCommunityData'
+import { screen, waitFor } from '@testing-library/react'
+import { axe } from 'jest-axe'
+import { render } from 'wrappers/testUtil'
+import CommunityPage from 'app/community/page'
+
+jest.mock('@apollo/client/react', () => ({
+ ...jest.requireActual('@apollo/client/react'),
+ useQuery: jest.fn(),
+}))
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: jest.fn(() => ({
+ push: jest.fn(),
+ })),
+}))
+
+describe('Community Page Accessibility', () => {
+ afterAll(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should have no accessibility violations', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: mockCommunityGraphQLData,
+ loading: false,
+ error: null,
+ })
+
+ const { container } = render()
+
+ await waitFor(() => {
+ expect(screen.getByText('OWASP Community')).toBeInTheDocument()
+ })
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/frontend/__tests__/e2e/data/mockCommunityData.ts b/frontend/__tests__/e2e/data/mockCommunityData.ts
new file mode 100644
index 0000000000..46d3892002
--- /dev/null
+++ b/frontend/__tests__/e2e/data/mockCommunityData.ts
@@ -0,0 +1,79 @@
+export const mockCommunityData = {
+ data: {
+ recentChapters: [
+ {
+ id: '1',
+ createdAt: '2025-03-18T01:03:09+00:00',
+ key: 'chapter_1',
+ leaders: ['Leader 1', 'Leader 3'],
+ name: 'Chapter 1',
+ suggestedLocation: 'Pune, Maharashtra, India',
+ },
+ {
+ id: '2',
+ createdAt: '2025-03-13T00:01:01+00:00',
+ key: 'chapter_2',
+ leaders: ['Leader 1', 'Leader 2'],
+ name: 'Chapter 2',
+ suggestedLocation: 'Location 2',
+ },
+ {
+ id: '3',
+ createdAt: '2025-02-25T02:04:57+00:00',
+ key: 'chapter_3',
+ leaders: ['Leader 1', 'Leader 2'],
+ name: 'Chapter 3',
+ suggestedLocation: 'Location 3',
+ },
+ ],
+ recentOrganizations: [
+ {
+ id: '1',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4',
+ login: 'org_1',
+ name: 'Organization 1',
+ },
+ {
+ id: '2',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4',
+ login: 'org_2',
+ name: 'Organization 2',
+ },
+ ],
+ snapshots: [
+ {
+ id: '1',
+ key: 'snapshot_1',
+ title: 'Snapshot 1',
+ startAt: '2025-01-01',
+ endAt: '2025-01-31',
+ },
+ {
+ id: '2',
+ key: 'snapshot_2',
+ title: 'Snapshot 2',
+ startAt: '2025-02-01',
+ endAt: '2025-02-28',
+ },
+ ],
+ topContributors: [
+ {
+ name: 'Contributor 1',
+ login: 'contributor_1',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/3531020?v=4',
+ },
+ {
+ name: 'Contributor 2',
+ login: 'contributor_2',
+ avatarUrl: 'https://avatars.githubusercontent.com/u/862914?v=4',
+ },
+ ],
+ statsOverview: {
+ activeChaptersStats: 150,
+ activeProjectsStats: 50,
+ contributorsStats: 5000,
+ countriesStats: 100,
+ slackWorkspaceStats: 35000,
+ },
+ },
+}
diff --git a/frontend/__tests__/e2e/pages/Chapters.spec.ts b/frontend/__tests__/e2e/pages/Chapters.spec.ts
index 5ffda82696..84544288e1 100644
--- a/frontend/__tests__/e2e/pages/Chapters.spec.ts
+++ b/frontend/__tests__/e2e/pages/Chapters.spec.ts
@@ -57,6 +57,6 @@ test.describe('Chapters Page', () => {
})
test('breadcrumb renders correct segments on /chapters', async ({ page }) => {
- await expectBreadCrumbsToBeVisible(page, ['Home', 'Chapters'])
+ await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Chapters'])
})
})
diff --git a/frontend/__tests__/e2e/pages/Community.spec.ts b/frontend/__tests__/e2e/pages/Community.spec.ts
new file mode 100644
index 0000000000..0f257596b6
--- /dev/null
+++ b/frontend/__tests__/e2e/pages/Community.spec.ts
@@ -0,0 +1,75 @@
+import { mockCommunityData } from '@e2e/data/mockCommunityData'
+import { mockHomeData } from '@e2e/data/mockHomeData'
+import { test, expect } from '@playwright/test'
+
+test.describe('Community Page', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route('**/graphql/', async (route) => {
+ const combinedData = {
+ data: {
+ ...mockHomeData.data,
+ ...mockCommunityData.data,
+ },
+ }
+ await route.fulfill({
+ status: 200,
+ json: combinedData,
+ })
+ })
+ await page.context().addCookies([
+ {
+ name: 'csrftoken',
+ value: 'abc123',
+ domain: 'localhost',
+ path: '/',
+ },
+ ])
+ await page.goto('/community')
+ })
+
+ test('should have a heading and intro text', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'OWASP Community' })).toBeVisible()
+ await expect(
+ page.getByText(
+ "Connect, collaborate, and contribute to the world's largest application security community."
+ )
+ ).toBeVisible()
+ await expect(page.getByPlaceholder('Search the OWASP community')).toBeVisible()
+ })
+
+ test('should have navigation cards', async ({ page }) => {
+ const navSection = page.locator('.grid.grid-cols-2').first()
+ await expect(navSection.getByRole('link', { name: 'Chapters' })).toBeVisible()
+ await expect(navSection.getByRole('link', { name: 'Members' })).toBeVisible()
+ await expect(navSection.getByRole('link', { name: 'Organizations' })).toBeVisible()
+ })
+
+ test('should have new chapters', async ({ page }) => {
+ await expect(page.getByText('New Chapters', { exact: true })).toBeVisible()
+ await expect(page.getByText('Chapter 1')).toBeVisible()
+ await expect(page.getByText('Pune, Maharashtra, India')).toBeVisible()
+ })
+
+ test('should have new organizations', async ({ page }) => {
+ await expect(page.getByText('New Organizations', { exact: true })).toBeVisible()
+ await expect(page.getByText('Organization 1')).toBeVisible()
+ })
+
+ test('should have snapshots', async ({ page }) => {
+ await expect(page.getByText('Snapshot 1')).toBeVisible()
+ await expect(page.getByText('Jan 1, 2025 - Jan 31, 2025')).toBeVisible()
+ })
+
+ test('should have top contributors', async ({ page }) => {
+ await expect(page.getByText('Top Contributors', { exact: true })).toBeVisible()
+ await expect(page.getByText('Contributor 1')).toBeVisible()
+ })
+
+ test('should have stats', async ({ page }) => {
+ await expect(page.getByText('Active Chapters')).toBeVisible()
+ await expect(page.getByText('150+', { exact: true })).toBeVisible()
+ await expect(page.getByText('Active Projects')).toBeVisible()
+ await expect(page.getByText('50+', { exact: true })).toBeVisible()
+ await expect(page.getByText(/5k\+/i)).toBeVisible()
+ })
+})
diff --git a/frontend/__tests__/e2e/pages/Organizations.spec.ts b/frontend/__tests__/e2e/pages/Organizations.spec.ts
index 5708c34ad4..5890d49a34 100644
--- a/frontend/__tests__/e2e/pages/Organizations.spec.ts
+++ b/frontend/__tests__/e2e/pages/Organizations.spec.ts
@@ -43,6 +43,6 @@ test.describe('Organization Page', () => {
})
test('breadcrumb renders correct segments on /organizations', async ({ page }) => {
- await expectBreadCrumbsToBeVisible(page, ['Home', 'Organizations'])
+ await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Organizations'])
})
})
diff --git a/frontend/__tests__/e2e/pages/Users.spec.ts b/frontend/__tests__/e2e/pages/Users.spec.ts
index ec058f46c5..de820905e7 100644
--- a/frontend/__tests__/e2e/pages/Users.spec.ts
+++ b/frontend/__tests__/e2e/pages/Users.spec.ts
@@ -61,6 +61,6 @@ test.describe('Users Page', () => {
await expect(page.getByText('2k')).toBeVisible()
})
test('breadcrumb renders correct segments on /members', async ({ page }) => {
- await expectBreadCrumbsToBeVisible(page, ['Home', 'Members'])
+ await expectBreadCrumbsToBeVisible(page, ['Home', 'Community', 'Members'])
})
})
diff --git a/frontend/__tests__/mockData/mockCommunityData.ts b/frontend/__tests__/mockData/mockCommunityData.ts
new file mode 100644
index 0000000000..09d5a3f5c9
--- /dev/null
+++ b/frontend/__tests__/mockData/mockCommunityData.ts
@@ -0,0 +1,72 @@
+export const mockCommunityGraphQLData = {
+ recentChapters: [
+ {
+ id: '1',
+ createdAt: '2025-01-01T10:00:00Z',
+ key: 'chapter-1',
+ leaders: ['Leader 1', 'Leader 2'],
+ name: 'OWASP Chapter 1',
+ suggestedLocation: 'Location 1',
+ },
+ {
+ id: '2',
+ createdAt: '2025-01-02T10:00:00Z',
+ key: 'chapter-2',
+ leaders: ['Leader 3'],
+ name: 'OWASP Chapter 2',
+ suggestedLocation: 'Location 2',
+ },
+ ],
+ recentOrganizations: [
+ {
+ id: 'org1',
+ avatarUrl: 'https://example.com/org1.png',
+ login: 'org1',
+ name: 'Organization 1',
+ },
+ {
+ id: 'org2',
+ avatarUrl: 'https://example.com/org2.png',
+ login: 'org2',
+ name: 'Organization 2',
+ },
+ ],
+ snapshots: [
+ {
+ id: 'snap1',
+ key: 'snapshot-1',
+ title: 'Snapshot 1',
+ startAt: '2025-01-01',
+ endAt: '2025-01-31',
+ },
+ {
+ id: 'snap2',
+ key: 'snapshot-2',
+ title: 'Snapshot 2',
+ startAt: '2025-02-01',
+ endAt: '2025-02-28',
+ },
+ ],
+ topContributors: [
+ {
+ id: 'user1',
+ avatarUrl: 'https://example.com/user1.png',
+ login: 'user1',
+ name: 'User 1',
+ bio: 'Bio 1',
+ },
+ {
+ id: 'user2',
+ avatarUrl: 'https://example.com/user2.png',
+ login: 'user2',
+ name: 'User 2',
+ bio: 'Bio 2',
+ },
+ ],
+ statsOverview: {
+ activeChaptersStats: 150,
+ activeProjectsStats: 50,
+ countriesStats: 100,
+ contributorsStats: 5000,
+ },
+}
diff --git a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
index 4291b39030..6a6b90b0a0 100644
--- a/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
+++ b/frontend/__tests__/unit/hooks/useBreadcrumbs.test.tsx
@@ -34,6 +34,7 @@ describe('useBreadcrumbs', () => {
expect(result.current).toEqual([
{ title: 'Home', path: '/' },
+ { title: 'Community', path: '/community' },
{ title: 'Members', path: '/members' },
])
})
@@ -78,6 +79,7 @@ describe('useBreadcrumbs', () => {
// Note: 'repositories' segment is hidden (in HIDDEN_SEGMENTS)
expect(result.current).toEqual([
{ title: 'Home', path: '/' },
+ { title: 'Community', path: '/community' },
{ title: 'Organizations', path: '/organizations' },
{ title: 'Test Organization', path: '/organizations/test-org' },
{ title: 'Test Repository', path: '/organizations/test-org/repositories/test-repo' },
@@ -91,34 +93,6 @@ describe('useBreadcrumbs', () => {
})
})
- describe('HIDDEN_SEGMENTS', () => {
- test('does not show "community" in breadcrumbs when present in path', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/community/forum')
-
- const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
-
- const titles = result.current.map((item) => item.title)
- expect(titles).not.toContain('Community')
- expect(result.current).toEqual([
- { title: 'Home', path: '/' },
- { title: 'Forum', path: '/community/forum' },
- ])
- })
- test('hides multiple HIDDEN_SEGMENTS in the same path', () => {
- ;(usePathname as jest.Mock).mockReturnValue('/community/mentees/profile')
-
- const { result } = renderHook(() => useBreadcrumbs(), { wrapper })
-
- const titles = result.current.map((item) => item.title)
- expect(titles).not.toContain('Community')
- expect(titles).not.toContain('Mentees')
- expect(result.current).toEqual([
- { title: 'Home', path: '/' },
- { title: 'Profile', path: '/community/mentees/profile' },
- ])
- })
- })
-
describe('Edge cases', () => {
test('handles null pathname', () => {
;(usePathname as jest.Mock).mockReturnValue(null)
diff --git a/frontend/__tests__/unit/pages/Community.test.tsx b/frontend/__tests__/unit/pages/Community.test.tsx
new file mode 100644
index 0000000000..2a915c658d
--- /dev/null
+++ b/frontend/__tests__/unit/pages/Community.test.tsx
@@ -0,0 +1,147 @@
+import { useQuery } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
+import { mockCommunityGraphQLData } from '@mockData/mockCommunityData'
+import { screen, waitFor } from '@testing-library/react'
+import { render } from 'wrappers/testUtil'
+import CommunityPage from 'app/community/page'
+
+jest.mock('@apollo/client/react', () => ({
+ ...jest.requireActual('@apollo/client/react'),
+ useQuery: jest.fn(),
+}))
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+const mockRouter = {
+ push: jest.fn(),
+}
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: jest.fn(() => mockRouter),
+}))
+
+describe('Community Page', () => {
+ beforeEach(() => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: mockCommunityGraphQLData,
+ loading: false,
+ error: null,
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders loading state', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ loading: true,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ const loadings = screen.getAllByAltText('Loading indicator')
+ expect(loadings.length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders error state', async () => {
+ ;(useQuery as unknown as jest.Mock).mockReturnValue({
+ data: null,
+ loading: false,
+ error: { message: 'Failed to fetch' },
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'GraphQL Request Failed',
+ color: 'danger',
+ })
+ )
+ })
+ })
+
+ test('renders hero section correctly', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('OWASP Community')).toBeInTheDocument()
+ expect(screen.getByText(/Connect, collaborate, and contribute/)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('Search the OWASP community')).toBeInTheDocument()
+ })
+ })
+
+ test('renders navigation cards', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Chapters')).toBeInTheDocument()
+ expect(screen.getByText('Members')).toBeInTheDocument()
+ expect(screen.getByText('Organizations')).toBeInTheDocument()
+ expect(screen.getAllByText('Snapshots').length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders new chapters section', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('New Chapters')).toBeInTheDocument()
+ expect(screen.getByText('OWASP Chapter 1')).toBeInTheDocument()
+ expect(screen.getByText('OWASP Chapter 2')).toBeInTheDocument()
+ expect(screen.getByText('Location 1')).toBeInTheDocument()
+ })
+ })
+
+ test('renders recent organizations section', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('New Organizations')).toBeInTheDocument()
+ expect(screen.getByText('Organization 1')).toBeInTheDocument()
+ })
+ })
+
+ test('renders snapshots section', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Snapshots').length).toBeGreaterThan(0)
+ expect(screen.getByText('Snapshot 1')).toBeInTheDocument()
+ expect(screen.getByText(/Jan 1, 2025 - Jan 31, 2025/)).toBeInTheDocument()
+ })
+ })
+
+ test('renders top contributors section', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Top Contributors')).toBeInTheDocument()
+ expect(screen.getByText('User 1')).toBeInTheDocument()
+ expect(screen.getByText('User 2')).toBeInTheDocument()
+ })
+ })
+
+ test('renders stats section', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Active Chapters')).toBeInTheDocument()
+ expect(screen.getByText('150+')).toBeInTheDocument()
+ expect(screen.getByText('Active Projects')).toBeInTheDocument()
+ expect(screen.getByText('50+')).toBeInTheDocument()
+ expect(screen.getByText('Countries')).toBeInTheDocument()
+ expect(screen.getByText('100+')).toBeInTheDocument()
+ expect(screen.getByText('Contributors')).toBeInTheDocument()
+ expect(screen.getByText(/5k\+/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/app/community/layout.tsx b/frontend/src/app/community/layout.tsx
new file mode 100644
index 0000000000..ab22a0c0fb
--- /dev/null
+++ b/frontend/src/app/community/layout.tsx
@@ -0,0 +1,9 @@
+import { Metadata } from 'next'
+import React from 'react'
+import { getStaticMetadata } from 'utils/metaconfig'
+
+export const metadata: Metadata = getStaticMetadata('community', '/community')
+
+export default function CommunityLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/frontend/src/app/community/page.tsx b/frontend/src/app/community/page.tsx
new file mode 100644
index 0000000000..233b923b0b
--- /dev/null
+++ b/frontend/src/app/community/page.tsx
@@ -0,0 +1,277 @@
+'use client'
+import { useQuery } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
+import millify from 'millify'
+import Image from 'next/image'
+import Link from 'next/link'
+import React, { useEffect } from 'react'
+import { FaMapMarkerAlt, FaUsers } from 'react-icons/fa'
+import { FaBuilding, FaCamera, FaLocationDot, FaPeopleGroup } from 'react-icons/fa6'
+import { ErrorDisplay } from 'app/global-error'
+import { GetCommunityPageDataDocument } from 'types/__generated__/communityQueries.generated'
+import { formatDate } from 'utils/dateFormatter'
+import { getMemberUrl } from 'utils/urlFormatter'
+import AnchorTitle from 'components/AnchorTitle'
+import ChapterCard from 'components/ChapterCard'
+import LoadingSpinner from 'components/LoadingSpinner'
+import MultiSearchBar from 'components/MultiSearch'
+import SecondaryCard from 'components/SecondaryCard'
+import { TruncatedText } from 'components/TruncatedText'
+
+interface NavCardProps {
+ href: string
+ icon: React.ReactNode
+ title: string
+ description: string
+}
+
+const NavCard = ({ href, icon, title, description }: NavCardProps) => (
+
+
{icon}
+ {title}
+ {description}
+
+)
+
+export default function CommunityPage() {
+ const {
+ data: graphQLData,
+ error: graphQLRequestError,
+ loading: isLoading,
+ } = useQuery(GetCommunityPageDataDocument)
+
+ useEffect(() => {
+ if (graphQLRequestError) {
+ addToast({
+ description: 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ }
+ }, [graphQLRequestError])
+
+ if (isLoading) {
+ return
+ }
+
+ if (graphQLRequestError || !graphQLData) {
+ return (
+
+ )
+ }
+
+ const data = graphQLData
+
+ const statsOverview = data.statsOverview
+ const statsData = statsOverview
+ ? [
+ { label: 'Active Chapters', value: statsOverview.activeChaptersStats },
+ { label: 'Active Projects', value: statsOverview.activeProjectsStats },
+ { label: 'Contributors', value: statsOverview.contributorsStats },
+ { label: 'Countries', value: statsOverview.countriesStats },
+ ]
+ : []
+
+ return (
+
+
+
+
+
+ OWASP Community
+
+
+ Connect, collaborate, and contribute to the world's largest application security
+ community.
+
+
+
+
+
+
+
+
Explore Community
+
+ }
+ title="Chapters"
+ description="Find local chapters near you"
+ />
+ }
+ title="Members"
+ description="Connect with community members"
+ />
+ }
+ title="Organizations"
+ description="Explore supporting organizations"
+ />
+ }
+ title="Snapshots"
+ description="View community snapshots"
+ />
+
+
+
+
+
+ }
+ className="overflow-hidden"
+ >
+
+ {data.recentChapters?.map((chapter) => (
+
+ ))}
+
+
+
+
+ }
+ className="!mb-0 flex h-full flex-col overflow-hidden"
+ >
+
+ {data.recentOrganizations?.map((org) => (
+
+
+ {org.avatarUrl && (
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ }
+ className="!mb-0 flex h-full flex-col overflow-hidden"
+ >
+
+ {data.snapshots?.map((snapshot) => (
+
+
+
+
+
+ {formatDate(snapshot.startAt)} - {formatDate(snapshot.endAt)}
+
+
+ ))}
+
+
+
+
+
+
+
+ }
+ >
+
+ {data.topContributors?.map((contributor) => (
+
+
+ {contributor.avatarUrl && (
+
+ )}
+
+ {contributor.name || contributor.login}
+
+
+
+ ))}
+
+
+
+
+ {statsData.map((stat) => (
+
+
+ {millify(stat.value)}+
+ {stat.label}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 859d1fb2b5..3d67a418b8 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -33,6 +33,7 @@ import { formatDate, formatDateRange } from 'utils/dateFormatter'
import { getMemberUrl } from 'utils/urlFormatter'
import AnchorTitle from 'components/AnchorTitle'
import CalendarButton from 'components/CalendarButton'
+import ChapterCard from 'components/ChapterCard'
import ChapterMapWrapper from 'components/ChapterMapWrapper'
import ContributorsList from 'components/ContributorsList'
import LeadersList from 'components/LeadersList'
@@ -227,38 +228,14 @@ export default function Home() {
>
{data?.recentChapters?.map((chapter) => (
-
-
-
-
-
-
-
-
-
- {formatDate(chapter.createdAt)}
-
- {chapter.suggestedLocation && (
-
-
-
-
- )}
-
-
- {chapter.leaders.length > 0 && (
-
-
-
-
- )}
-
+
))}
diff --git a/frontend/src/components/ChapterCard.tsx b/frontend/src/components/ChapterCard.tsx
new file mode 100644
index 0000000000..43200d0863
--- /dev/null
+++ b/frontend/src/components/ChapterCard.tsx
@@ -0,0 +1,51 @@
+'use client'
+import Link from 'next/link'
+import { FaCalendar, FaMapMarkerAlt } from 'react-icons/fa'
+import { HiUserGroup } from 'react-icons/hi'
+import { formatDate } from 'utils/dateFormatter'
+import LeadersList from 'components/LeadersList'
+import { TruncatedText } from 'components/TruncatedText'
+
+interface ChapterCardProps {
+ chapterKey: string
+ name: string
+ createdAt: number
+ suggestedLocation: string | null
+ leaders: string[]
+}
+
+const ChapterCard = ({
+ chapterKey,
+ name,
+ createdAt,
+ suggestedLocation,
+ leaders,
+}: ChapterCardProps) => (
+
+
+
+
+
+
+
+
+
+ {formatDate(createdAt)}
+
+ {suggestedLocation && (
+
+
+
+
+ )}
+
+ {leaders.length > 0 && (
+
+
+
+
+ )}
+
+)
+
+export default ChapterCard
diff --git a/frontend/src/hooks/useBreadcrumbs.ts b/frontend/src/hooks/useBreadcrumbs.ts
index 3ecf8b8b21..bacbc5e2b8 100644
--- a/frontend/src/hooks/useBreadcrumbs.ts
+++ b/frontend/src/hooks/useBreadcrumbs.ts
@@ -5,14 +5,15 @@ import { formatBreadcrumbTitle } from 'utils/breadcrumb'
export type { BreadcrumbItem } from 'types/breadcrumb'
-const HIDDEN_SEGMENTS = new Set(['community', 'mentees', 'modules', 'programs', 'repositories'])
+const HIDDEN_SEGMENTS = new Set(['mentees', 'modules', 'programs', 'repositories'])
+const COMMUNITY_RELATED_PATHS = ['/chapters', '/members', '/organizations']
function buildBreadcrumbItems(
pathname: string | null,
registeredItems: BreadcrumbItem[]
): BreadcrumbItem[] {
const registeredMap = new Map()
- for (const item of registeredItems) {
+ for (const item of registeredItems || []) {
registeredMap.set(item.path, item)
}
@@ -22,6 +23,13 @@ function buildBreadcrumbItems(
return items
}
+ if (COMMUNITY_RELATED_PATHS.some((path) => pathname.startsWith(path))) {
+ items.push({
+ title: 'Community',
+ path: '/community',
+ })
+ }
+
const segments = pathname.split('/').filter(Boolean)
let currentPath = ''
diff --git a/frontend/src/server/queries/communityQueries.ts b/frontend/src/server/queries/communityQueries.ts
new file mode 100644
index 0000000000..548d0e6b90
--- /dev/null
+++ b/frontend/src/server/queries/communityQueries.ts
@@ -0,0 +1,39 @@
+import { gql } from '@apollo/client'
+
+export const GET_COMMUNITY_PAGE_DATA = gql`
+ query GetCommunityPageData {
+ recentChapters(limit: 6) {
+ id
+ createdAt
+ key
+ leaders
+ name
+ suggestedLocation
+ }
+ recentOrganizations(limit: 5) {
+ id
+ avatarUrl
+ login
+ name
+ }
+ snapshots(limit: 5) {
+ id
+ key
+ title
+ startAt
+ endAt
+ }
+ topContributors(hasFullName: true, limit: 12) {
+ id
+ avatarUrl
+ login
+ name
+ }
+ statsOverview {
+ activeChaptersStats
+ activeProjectsStats
+ contributorsStats
+ countriesStats
+ }
+ }
+`
diff --git a/frontend/src/types/__generated__/communityQueries.generated.ts b/frontend/src/types/__generated__/communityQueries.generated.ts
new file mode 100644
index 0000000000..db7c4ee1f9
--- /dev/null
+++ b/frontend/src/types/__generated__/communityQueries.generated.ts
@@ -0,0 +1,10 @@
+import * as Types from './graphql';
+
+import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+export type GetCommunityPageDataQueryVariables = Types.Exact<{ [key: string]: never; }>;
+
+
+export type GetCommunityPageDataQuery = { recentChapters: Array<{ __typename: 'ChapterNode', id: string, createdAt: number, key: string, leaders: Array, name: string, suggestedLocation: string | null }>, recentOrganizations: Array<{ __typename: 'OrganizationNode', id: string, avatarUrl: string, login: string, name: string }>, snapshots: Array<{ __typename: 'SnapshotNode', id: string, key: string, title: string, startAt: any, endAt: any }>, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }>, statsOverview: { __typename: 'StatsNode', activeChaptersStats: number, activeProjectsStats: number, contributorsStats: number, countriesStats: number } };
+
+
+export const GetCommunityPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCommunityPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentOrganizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"snapshots"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"startAt"}},{"kind":"Field","name":{"kind":"Name","value":"endAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"12"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts
index c1eb07d419..5b3159915d 100644
--- a/frontend/src/types/__generated__/graphql.ts
+++ b/frontend/src/types/__generated__/graphql.ts
@@ -710,6 +710,7 @@ export type Query = {
recentChapters: Array;
recentIssues: Array;
recentMilestones: Array;
+ recentOrganizations: Array;
recentPosts: Array;
recentProjects: Array;
recentPullRequests: Array;
@@ -858,6 +859,11 @@ export type QueryRecentMilestonesArgs = {
};
+export type QueryRecentOrganizationsArgs = {
+ limit?: Scalars['Int']['input'];
+};
+
+
export type QueryRecentPostsArgs = {
limit?: Scalars['Int']['input'];
};
diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts
index d174adf440..8e33f6e579 100644
--- a/frontend/src/utils/metadata.ts
+++ b/frontend/src/utils/metadata.ts
@@ -48,6 +48,13 @@ export const METADATA_CONFIG = {
pageTitle: 'About',
type: 'website',
},
+ community: {
+ description:
+ 'Connect, collaborate, and contribute to the OWASP community. Explore chapters, members, organizations, and community snapshots.',
+ keywords: ['OWASP community', 'chapters', 'members', 'organizations', 'snapshots'],
+ pageTitle: 'Community',
+ type: 'website',
+ },
organizations: {
description: 'Explore OWASP organizations and their contributions to web security.',
keywords: ['OWASP organizations', 'security organizations', 'web security'],