diff --git a/frontend/__tests__/unit/utils/structuredData.test.ts b/frontend/__tests__/unit/utils/structuredData.test.ts new file mode 100644 index 0000000000..27ba2d730e --- /dev/null +++ b/frontend/__tests__/unit/utils/structuredData.test.ts @@ -0,0 +1,141 @@ +import type { UserDetails } from 'types/user' +import { generateProfilePageStructuredData } from 'utils/structuredData' + +describe('generateProfilePageStructuredData', () => { + const mockUser: UserDetails = { + avatarUrl: 'https://example.com/avatar.jpg', + bio: 'Security researcher and OWASP contributor', + company: 'Security Corp', + contributionsCount: 150, + createdAt: '2020-01-01T00:00:00Z', + email: 'user@example.com', + followersCount: 500, + followingCount: 200, + key: 'testuser', + location: 'San Francisco, CA, USA', + login: 'testuser', + name: 'Test User', + publicRepositoriesCount: 25, + updatedAt: '2021-02-03T00:00:00Z', + url: 'https://github.com/testuser', + } + + it('should generate valid ProfilePage structured data', () => { + const result = generateProfilePageStructuredData(mockUser) + + expect(result).toEqual({ + '@context': 'https://schema.org', + '@type': 'ProfilePage', + dateCreated: '1970-01-01T00:33:40.000Z', + dateModified: '1970-01-01T00:33:41.000Z', + mainEntity: { + '@type': 'Person', + address: 'San Francisco, CA, USA', + description: 'Security researcher and OWASP contributor', + identifier: 'testuser', + image: 'https://example.com/avatar.jpg', + interactionStatistic: [ + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/FollowAction', + userInteractionCount: 500, + }, + ], + memberOf: { + '@type': 'Organization', + name: 'OWASP Community', + url: 'https://nest.owasp.org/members', + }, + name: 'Test User', + sameAs: ['https://github.com/testuser'], + url: 'https://nest.owasp.org/members/testuser', + worksFor: { + '@type': 'Organization', + name: 'Security Corp', + }, + }, + }) + }) + + it('should handle user without optional fields', () => { + const minimalUser: UserDetails = { + avatarUrl: 'https://example.com/avatar.jpg', + contributionsCount: 0, + createdAt: '2020-01-01T00:00:00Z', + followersCount: 0, + followingCount: 0, + key: 'basicuser', + login: 'basicuser', + publicRepositoriesCount: 0, + updatedAt: '2021-02-03T00:00:00Z', + url: 'https://github.com/basicuser', + } + + const result = generateProfilePageStructuredData(minimalUser) + + expect(result.mainEntity.name).toBe('basicuser') + expect(result.mainEntity.description).toBeUndefined() + expect(result.mainEntity.worksFor).toBeUndefined() + expect(result.mainEntity.address).toBeUndefined() + expect(result.mainEntity.interactionStatistic).toBeUndefined() + + // These should always be present + expect(result.mainEntity.memberOf).toBeDefined() + }) + + it('should include interaction statistics only when followers count > 0', () => { + const userWithFollowers = { ...mockUser, followersCount: 100 } + const userWithoutFollowers = { ...mockUser, followersCount: 0 } + + const resultWithFollowers = generateProfilePageStructuredData(userWithFollowers) + const resultWithoutFollowers = generateProfilePageStructuredData(userWithoutFollowers) + + expect(resultWithFollowers.mainEntity.interactionStatistic).toBeDefined() + expect(resultWithFollowers.mainEntity.interactionStatistic?.[0].userInteractionCount).toBe(100) + + expect(resultWithoutFollowers.mainEntity.interactionStatistic).toBeUndefined() + }) + + it('should use custom base URL when provided', () => { + const result = generateProfilePageStructuredData(mockUser, 'https://custom.example.com') + + expect(result.mainEntity.url).toBe('https://custom.example.com/members/testuser') + }) + + it('should handle user with company but no location', () => { + const userWithCompanyOnly = { + ...mockUser, + company: 'Tech Corp', + location: undefined, + } + + const result = generateProfilePageStructuredData(userWithCompanyOnly) + + expect(result.mainEntity.worksFor).toEqual({ + '@type': 'Organization', + name: 'Tech Corp', + }) + expect(result.mainEntity.address).toBeUndefined() + }) + + it('should handle user with location but no company', () => { + const userWithLocationOnly = { + ...mockUser, + company: undefined, + location: 'New York, NY', + } + + const result = generateProfilePageStructuredData(userWithLocationOnly) + + expect(result.mainEntity.address).toEqual('New York, NY') + expect(result.mainEntity.worksFor).toBeUndefined() + }) + + it('should fallback to login when name is not provided', () => { + const userWithoutName = { ...mockUser, name: undefined } + + const result = generateProfilePageStructuredData(userWithoutName) + + expect(result.mainEntity.name).toBe('testuser') + }) +}) diff --git a/frontend/src/app/members/[memberKey]/layout.tsx b/frontend/src/app/members/[memberKey]/layout.tsx index 8b79b5c936..bc78d19cdd 100644 --- a/frontend/src/app/members/[memberKey]/layout.tsx +++ b/frontend/src/app/members/[memberKey]/layout.tsx @@ -1,8 +1,10 @@ import { Metadata } from 'next' import React from 'react' import { apolloClient } from 'server/apolloClient' -import { GET_USER_METADATA } from 'server/queries/userQueries' +import { GET_USER_METADATA, GET_USER_DATA } from 'server/queries/userQueries' import { generateSeoMetadata } from 'utils/metaconfig' +import { generateProfilePageStructuredData } from 'utils/structuredData' +import StructuredDataScript from 'components/StructuredDataScript' export async function generateMetadata({ params, @@ -29,6 +31,30 @@ export async function generateMetadata({ : null } -export default function UserDetailsLayout({ children }: { children: React.ReactNode }) { - return children +export default async function UserDetailsLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ memberKey: string }> +}) { + const { memberKey } = await params + + const { data } = await apolloClient.query({ + query: GET_USER_DATA, + variables: { + key: memberKey, + }, + }) + + if (!data?.user?.login) { + return children + } + + return ( + <> + + {children} + + ) } diff --git a/frontend/src/components/StructuredDataScript.tsx b/frontend/src/components/StructuredDataScript.tsx new file mode 100644 index 0000000000..3e70b99ad7 --- /dev/null +++ b/frontend/src/components/StructuredDataScript.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { ProfilePageStructuredData } from 'types/profilePageStructuredData' + +interface StructuredDataScriptProps { + data: ProfilePageStructuredData +} + +// dangerouslySetInnerHTML injects the JSON data as a script tag. +const StructuredDataScript: React.FC = ({ data }) => { + return ( +